wrolpi/wrolpi/collections/test/test_reorganize.py

2790 lines
110 KiB
Python
Raw Normal View History

"""
Tests for the collection reorganization feature.
"""
import pytest
from modules.archive.lib import get_archive_downloader_config
from modules.archive.models import Archive
from modules.videos.lib import get_videos_downloader_config
from modules.videos.models import Video, Channel
from wrolpi.collections import Collection
from wrolpi.collections.errors import UnknownCollection, ReorganizationConflict
from wrolpi.collections.reorganize import (
get_reorganization_preview,
execute_reorganization,
get_reorganization_status,
get_collections_needing_reorganization,
execute_batch_reorganization,
get_batch_reorganization_status,
ReorganizationPreview,
)
from wrolpi.db import get_db_session
from wrolpi.files.models import FileGroup
def test_collection_file_format_serialization(test_session, test_directory):
"""Collection file_format should be persisted, serialized to config, and included in JSON."""
collection = Collection(
name='test.com',
kind='domain',
directory=test_directory / 'archive' / 'test.com',
file_format='%(download_year)s/%(title)s.%(ext)s'
)
test_session.add(collection)
test_session.commit()
# Test column persistence
test_session.refresh(collection)
assert collection.file_format == '%(download_year)s/%(title)s.%(ext)s'
# Test to_config
config = collection.to_config()
assert config['file_format'] == '%(download_year)s/%(title)s.%(ext)s'
# Test __json__
json_data = collection.__json__()
assert 'needs_reorganization' in json_data
assert 'file_format' in json_data
def test_collection_needs_reorganization_property(test_session, test_directory):
"""Collection.needs_reorganization should be True when file_format differs or is None."""
collection = Collection(
name='test.com',
kind='domain',
directory=test_directory / 'archive' / 'test.com',
file_format=None # Test None case
)
test_session.add(collection)
test_session.commit()
# None format should need reorganization
assert collection.needs_reorganization is True
# Format matching config should NOT need reorganization
config = get_archive_downloader_config()
collection.file_format = config.file_name_format
test_session.commit()
assert collection.needs_reorganization is False
# Different format SHOULD need reorganization
collection.file_format = '%(download_year)s/%(title)s.%(ext)s'
test_session.commit()
assert collection.needs_reorganization is True
def test_collection_from_config_loads_file_format(test_session, test_directory):
"""Collection.from_config should load file_format from config."""
config_data = {
'name': 'test.com',
'kind': 'domain',
'directory': str(test_directory / 'archive' / 'test.com'),
'file_format': '%(download_year)s/%(title)s.%(ext)s'
}
collection = Collection.from_config(test_session, config_data)
test_session.commit()
assert collection.file_format == '%(download_year)s/%(title)s.%(ext)s'
@pytest.mark.parametrize("func_name,error_type,error_match,setup", [
("get_reorganization_preview", UnknownCollection, None, "unknown_id"),
("execute_reorganization", UnknownCollection, None, "unknown_id"),
("get_reorganization_preview", ValueError, "has no directory", "no_directory"),
("execute_reorganization", ValueError, "has no directory", "no_directory"),
])
def test_reorganization_error_handling(func_name, error_type, error_match, setup, test_session, test_directory):
"""Both preview and execute should raise appropriate errors for invalid inputs."""
func = get_reorganization_preview if func_name == "get_reorganization_preview" else execute_reorganization
if setup == "unknown_id":
# Test with non-existent collection ID
with pytest.raises(error_type):
func(99999, test_session)
elif setup == "no_directory":
# Test with collection that has no directory
collection = Collection(
name='test.com',
kind='domain',
directory=None,
)
test_session.add(collection)
test_session.commit()
with pytest.raises(error_type, match=error_match):
func(collection.id, test_session)
def test_get_reorganization_preview_domain_collection(test_session, test_directory, archive_factory):
"""get_reorganization_preview should work for domain collections."""
# Create domain collection
domain_dir = test_directory / 'archive' / 'test.com'
domain_dir.mkdir(parents=True, exist_ok=True)
collection = Collection(
name='test.com',
kind='domain',
directory=domain_dir,
file_format='%(download_datetime)s_%(title)s.%(ext)s'
)
test_session.add(collection)
test_session.commit()
# Create an archive using the factory (it uses test_session internally)
archive = archive_factory(domain='test.com', url='https://test.com/article')
# The factory creates a new collection, so associate archive with our collection
archive.collection_id = collection.id
test_session.commit()
preview = get_reorganization_preview(collection.id, test_session)
assert isinstance(preview, ReorganizationPreview)
assert preview.collection_id == collection.id
assert preview.collection_name == 'test.com'
assert preview.total_files >= 0
assert preview.new_file_format is not None
@pytest.mark.asyncio
async def test_execute_reorganization_no_files_to_move(async_client, test_directory):
"""execute_reorganization should return empty job_id when no files need moving."""
# Create domain collection with matching file_format
domain_dir = test_directory / 'archive' / 'test.com'
domain_dir.mkdir(parents=True, exist_ok=True)
config = get_archive_downloader_config()
with get_db_session(commit=True) as session:
collection = Collection(
name='test.com',
kind='domain',
directory=domain_dir,
file_format=config.file_name_format # Same as config, no reorganization needed
)
session.add(collection)
session.commit()
collection_id = collection.id
with get_db_session(commit=True) as session:
job_id = execute_reorganization(collection_id, session)
assert job_id == ''
@pytest.mark.asyncio
async def test_get_reorganization_status_unknown_job(async_client):
"""get_reorganization_status should return unknown status for non-existent job."""
status = get_reorganization_status('nonexistent-job-id')
assert status['status'] == 'unknown'
assert 'error' in status
@pytest.mark.asyncio
async def test_get_reorganization_status_failed_job(async_client):
"""A failed reorganize job must report status=failed with the recorded error.
reset_status() clears worker_status['error'], so the error has to come from
the job record itself — otherwise the UI would show an idle/running job.
"""
from wrolpi.files.worker import file_worker
file_worker._fail_job('reorg-failed', 'disk full')
status = get_reorganization_status('reorg-failed')
assert status['status'] == 'failed'
assert status['error'] == 'disk full'
@pytest.mark.asyncio
async def test_handle_reorganize_marks_job_failed_on_exception(
async_client, test_directory, video_factory
):
"""handle_reorganize must mark its job failed when a move raises.
Regression: the except path sent a failure event but left the job
'pending', so waiters (and the batch reorganize poller) spun forever.
"""
from unittest import mock
from wrolpi.files.worker import file_worker, FileTask, FileTaskType
channel_dir = test_directory / 'videos' / 'FailChannel'
channel_dir.mkdir(parents=True, exist_ok=True)
with get_db_session(commit=True) as session:
collection = Collection(
name='FailChannel',
kind='channel',
directory=channel_dir,
file_format='%(title)s.%(ext)s',
)
session.add(collection)
session.flush()
channel = Channel(name='FailChannel', collection_id=collection.id, directory=channel_dir)
session.add(channel)
session.commit()
channel_id = channel.id
video = video_factory(channel_id=channel_id, title='will_fail', with_video_file=True)
with get_db_session() as session:
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
video_obj = session.get(Video, video.id)
old_path = video_obj.file_group.primary_path
dest = channel_dir / 'renamed_will_fail.mp4'
job_id = 'reorganize-will-fail'
task = FileTask(
task_type=FileTaskType.reorganize,
paths=[],
move_mappings=[(old_path, dest)],
job_id=job_id,
)
file_worker._set_job_status(job_id, 'pending')
with mock.patch('wrolpi.files.reorganize_job.shutil.move', side_effect=OSError('disk full')):
await file_worker.handle_reorganize(task)
assert file_worker.get_job_status(job_id) == 'failed'
assert 'disk full' in (file_worker.get_job_error(job_id) or '')
status = get_reorganization_status(job_id)
assert status['status'] == 'failed'
assert 'disk full' in (status['error'] or '')
def test_filegroup_primary_path_lookup_consistency(test_session, test_directory):
"""FileGroup.primary_path returns a Path object, but IN queries use strings.
This test ensures that the handle_reorganize function correctly converts
the primary_path to a string when building the lookup dictionary.
The bug was: fg_by_path[fg.primary_path] stored Path keys, but lookups
used str(source_path), causing all lookups to fail.
"""
import pathlib
from wrolpi.files.models import FileGroup
# Create a test file
video_dir = test_directory / 'videos' / 'TestChannel'
video_dir.mkdir(parents=True, exist_ok=True)
video_file = video_dir / 'test_video.mp4'
video_file.touch()
# Create FileGroup
fg = FileGroup()
fg.directory = video_dir
fg.primary_path = video_file
fg.files = [{'path': 'test_video.mp4', 'mimetype': 'video/mp4'}]
test_session.add(fg)
test_session.commit()
# Verify primary_path is a Path object (this is how MediaPathType works)
test_session.refresh(fg)
assert isinstance(fg.primary_path, pathlib.Path), \
f"Expected Path, got {type(fg.primary_path)}"
# Test the pattern used in handle_reorganize:
# Query returns FileGroups, build lookup with string keys for string lookups
source_paths = [str(video_file)]
file_groups = test_session.query(FileGroup).filter(
FileGroup.primary_path.in_(source_paths)
).all()
assert len(file_groups) == 1, "Query should find the FileGroup"
# Build lookup dict - this is where the bug was
# WRONG: fg_by_path[fg.primary_path] (Path key, str lookup fails)
# CORRECT: fg_by_path[str(fg.primary_path)] (str key, str lookup works)
fg_by_path_wrong = {fg.primary_path: fg for fg in file_groups}
fg_by_path_correct = {str(fg.primary_path): fg for fg in file_groups}
# The source path as string (as used in move_mappings)
lookup_key = str(video_file)
# This was the bug - Path key doesn't match string lookup
assert lookup_key not in fg_by_path_wrong, \
"String key should NOT be found in Path-keyed dict"
# This is the correct behavior after fix
assert lookup_key in fg_by_path_correct, \
"String key SHOULD be found in string-keyed dict"
@pytest.mark.asyncio
async def test_reorganize_updates_filegroup_data_fields(async_client, test_directory, archive_factory):
"""FileGroup.data fields should be updated after reorganization.
After reorganization:
- files[].path is updated to new filename
- data.singlefile_path should match files[].path
- data.screenshot_path should match the new screenshot filename
"""
from wrolpi.files.worker import file_worker, FileTask, FileTaskType
import asyncio
# Create domain collection with a specific directory
domain_dir = test_directory / 'archive' / 'test.com'
domain_dir.mkdir(parents=True, exist_ok=True)
with get_db_session(commit=True) as session:
collection = Collection(
name='test.com',
kind='domain',
directory=domain_dir,
# Old format - files are currently named with this pattern
file_format='%(download_datetime)s_%(title)s.%(ext)s'
)
session.add(collection)
session.commit()
collection_id = collection.id
# Create an archive using factory - this creates files with the default format
archive = archive_factory(domain='test.com', url='https://test.com/article', title='Test Article')
with get_db_session(commit=True) as session:
# Link archive to our collection
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
archive_obj = session.get(Archive, archive.id)
archive_obj.collection_id = collection_id
session.commit()
# Get the current file paths
fg = archive_obj.file_group
old_primary_path = fg.primary_path
old_singlefile_name = fg.data.get('singlefile_path')
old_screenshot_name = fg.data.get('screenshot_path')
fg_id = fg.id
# Verify data fields are populated
assert old_singlefile_name is not None, "singlefile_path should be set in data"
assert old_screenshot_name is not None, "screenshot_path should be set in data"
# Verify data matches files
files_html = [f for f in fg.files if f['path'].endswith('.html') and '.readability' not in f['path']]
assert len(files_html) == 1
assert files_html[0]['path'] == old_singlefile_name, "data.singlefile_path should match files[].path"
# Create move mapping: rename files to a new pattern
new_stem = 'renamed_article'
new_primary_path = domain_dir / f'{new_stem}.html'
move_mappings = [(old_primary_path, new_primary_path)]
# Execute reorganization via file worker
task = FileTask(
task_type=FileTaskType.reorganize,
paths=[],
move_mappings=move_mappings,
job_id='test-reorganize-data-bug',
)
await file_worker.handle_reorganize(task)
# Give time for DB updates
await asyncio.sleep(0.1)
# Verify results
with get_db_session() as session:
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
fg = session.get(FileGroup, fg_id)
# Verify files were physically renamed
assert new_primary_path.exists(), "New primary file should exist"
assert not old_primary_path.exists(), "Old primary file should not exist"
# Verify FileGroup.files[] was updated
new_files_html = [f for f in fg.files if f['path'].endswith('.html') and '.readability' not in f['path']]
assert len(new_files_html) == 1
new_file_name = new_files_html[0]['path']
assert new_file_name == f'{new_stem}.html', f"files[].path should be updated to new filename, got {new_file_name}"
# Verify FileGroup.data.singlefile_path was updated to match files[].path
new_singlefile_name = fg.data.get('singlefile_path')
assert new_singlefile_name == new_file_name, \
f"data.singlefile_path should match files[].path after reorganization. " \
f"Expected '{new_file_name}', got '{new_singlefile_name}'"
# Verify FileGroup.data.screenshot_path was updated
new_screenshot_name = fg.data.get('screenshot_path')
expected_screenshot = f'{new_stem}.png'
assert new_screenshot_name == expected_screenshot, \
f"data.screenshot_path should be updated after reorganization. " \
f"Expected '{expected_screenshot}', got '{new_screenshot_name}'"
# Verify readability paths were also updated
new_readability_name = fg.data.get('readability_path')
expected_readability = f'{new_stem}.readability.html'
assert new_readability_name == expected_readability, \
f"data.readability_path should be updated after reorganization. " \
f"Expected '{expected_readability}', got '{new_readability_name}'"
# ============================================================================
# Batch Reorganization Tests
# ============================================================================
@pytest.mark.parametrize("kind,config_getter,base_dir,names", [
("channel", "get_videos_downloader_config", "videos", ["Channel1", "Channel2", "Channel3"]),
("domain", "get_archive_downloader_config", "archive", ["example.com", "test.org", "done.net"]),
])
def test_get_collections_needing_reorganization(test_session, test_directory, kind, config_getter, base_dir, names):
"""Create 3 collections, 2 need reorganization, verify only 2 returned."""
# Get current config format
config = get_videos_downloader_config() if config_getter == "get_videos_downloader_config" else get_archive_downloader_config()
# Create base directory
parent_dir = test_directory / base_dir
parent_dir.mkdir(parents=True, exist_ok=True)
# Collection 1: Needs reorganization (different format)
dir1 = parent_dir / names[0]
dir1.mkdir(parents=True, exist_ok=True)
collection1 = Collection(
name=names[0],
kind=kind,
directory=dir1,
file_format='%(old_format)s.%(ext)s' # Different from config
)
test_session.add(collection1)
test_session.flush()
if kind == 'channel':
test_session.add(Channel(name=names[0], collection_id=collection1.id))
# Collection 2: Needs reorganization (None format)
dir2 = parent_dir / names[1]
dir2.mkdir(parents=True, exist_ok=True)
collection2 = Collection(
name=names[1],
kind=kind,
directory=dir2,
file_format=None # Never been synced
)
test_session.add(collection2)
test_session.flush()
if kind == 'channel':
test_session.add(Channel(name=names[1], collection_id=collection2.id))
# Collection 3: Does NOT need reorganization (matches config)
dir3 = parent_dir / names[2]
dir3.mkdir(parents=True, exist_ok=True)
collection3 = Collection(
name=names[2],
kind=kind,
directory=dir3,
file_format=config.file_name_format # Matches config
)
test_session.add(collection3)
test_session.flush()
if kind == 'channel':
test_session.add(Channel(name=names[2], collection_id=collection3.id))
test_session.commit()
# Get collections needing reorganization
result = get_collections_needing_reorganization(kind, test_session)
assert 'collections' in result
assert result['total_collections'] == 2
collection_ids = {c['collection_id'] for c in result['collections']}
assert collection1.id in collection_ids
assert collection2.id in collection_ids
assert collection3.id not in collection_ids
def test_get_collections_needing_reorganization_empty(test_session, test_directory):
"""When no collections need reorganization, return empty list."""
config = get_videos_downloader_config()
# Create channel that does NOT need reorganization
videos_dir = test_directory / 'videos'
channel_dir = videos_dir / 'SyncedChannel'
channel_dir.mkdir(parents=True, exist_ok=True)
collection = Collection(
name='SyncedChannel',
kind='channel',
directory=channel_dir,
file_format=config.file_name_format
)
test_session.add(collection)
test_session.flush()
channel = Channel(name='SyncedChannel', collection_id=collection.id)
test_session.add(channel)
test_session.commit()
result = get_collections_needing_reorganization('channel', test_session)
assert result['collections'] == []
assert result['total_collections'] == 0
assert result['total_files_needing_move'] == 0
def test_get_collections_needing_reorganization_includes_sample_move(
test_session, test_directory, video_factory
):
"""Each collection should include one sample file move preview."""
config = get_videos_downloader_config()
# Create channel with old format
videos_dir = test_directory / 'videos'
channel_dir = videos_dir / 'TestChannel'
channel_dir.mkdir(parents=True, exist_ok=True)
collection = Collection(
name='TestChannel',
kind='channel',
directory=channel_dir,
file_format='%(old_format)s.%(ext)s'
)
test_session.add(collection)
test_session.flush()
channel = Channel(name='TestChannel', collection_id=collection.id, directory=channel_dir)
test_session.add(channel)
test_session.commit()
# Create a video in this channel using factory
video = video_factory(channel_id=channel.id)
result = get_collections_needing_reorganization('channel', test_session)
assert len(result['collections']) == 1
collection_info = result['collections'][0]
assert collection_info['collection_id'] == collection.id
assert collection_info['collection_name'] == 'TestChannel'
assert 'sample_move' in collection_info
# sample_move should be a dict with old_path and new_path (or None if no files need moving)
@pytest.mark.asyncio
async def test_batch_status_includes_batch_job_id(async_client, test_directory, video_factory):
"""batch_status should include batch_job_id so frontend can resume polling.
Bug: Frontend sets batchJobId='active' as a marker when resuming, but then
uses this literal string for API calls, causing "Batch job active not found".
Fix: batch_status must include the real batch_job_id so frontend can use it.
"""
from datetime import datetime, timezone
from wrolpi.files.worker import file_worker
import asyncio
# Create channel needing reorganization
channel_dir = test_directory / 'videos' / 'TestChannel'
channel_dir.mkdir(parents=True, exist_ok=True)
with get_db_session(commit=True) as session:
collection = Collection(
name='TestChannel',
kind='channel',
directory=channel_dir,
file_format='%(old_format)s.%(ext)s' # Different from current config
)
session.add(collection)
session.flush()
channel = Channel(name='TestChannel', collection_id=collection.id, directory=channel_dir)
session.add(channel)
session.commit()
# Create video with proper metadata
video = video_factory(
channel_id=1,
title='Test Video',
with_video_file=True,
with_info_json={'uploader': 'Tester', 'id': 'abc123'},
upload_date=datetime(2024, 6, 15, tzinfo=timezone.utc),
)
# Start batch reorganization
with get_db_session() as session:
result = execute_batch_reorganization('channel', session)
batch_job_id = result['batch_job_id']
assert batch_job_id, "Should return batch_job_id"
# Transfer the task from pending queue to worker queue, then start processing
file_worker.transfer_queue()
# Start the worker processing in a task (it will set up status before processing)
process_task = asyncio.create_task(file_worker.process_queue())
# Wait briefly for worker to start processing and set status
await asyncio.sleep(0.2)
# Check that batch_status includes the batch_job_id
worker_status = file_worker.status
batch_status = worker_status.get('batch_status', {})
# Cancel the processing task to clean up
process_task.cancel()
try:
await process_task
except asyncio.CancelledError:
pass
assert 'batch_job_id' in batch_status, (
"batch_status must include batch_job_id for frontend resume. "
f"Got: {list(batch_status.keys())}"
)
assert batch_status['batch_job_id'] == batch_job_id, (
f"batch_status.batch_job_id should match returned ID. "
f"Expected: {batch_job_id}, got: {batch_status.get('batch_job_id')}"
)
@pytest.mark.asyncio
@pytest.mark.parametrize("kind,base_dir,names", [
("channel", "videos", ["Channel1", "Channel2"]),
("domain", "archive", ["example.com", "test.org"]),
])
async def test_execute_batch_reorganization(async_client, test_directory, kind, base_dir, names):
"""Execute batch reorganization, verify all collections processed."""
parent_dir = test_directory / base_dir
parent_dir.mkdir(parents=True, exist_ok=True)
with get_db_session(commit=True) as session:
# Create two collections that need reorganization
dir1 = parent_dir / names[0]
dir1.mkdir(parents=True, exist_ok=True)
collection1 = Collection(
name=names[0],
kind=kind,
directory=dir1,
file_format='%(old_format)s.%(ext)s'
)
session.add(collection1)
session.flush()
if kind == 'channel':
session.add(Channel(name=names[0], collection_id=collection1.id, directory=dir1))
dir2 = parent_dir / names[1]
dir2.mkdir(parents=True, exist_ok=True)
collection2 = Collection(
name=names[1],
kind=kind,
directory=dir2,
file_format='%(old_format)s.%(ext)s'
)
session.add(collection2)
session.flush()
if kind == 'channel':
session.add(Channel(name=names[1], collection_id=collection2.id, directory=dir2))
session.commit()
# Execute batch reorganization
with get_db_session() as session:
result = execute_batch_reorganization(kind, session)
assert 'batch_job_id' in result
assert result['batch_job_id'].startswith('batch-reorganize-')
assert result['collection_count'] >= 0 # May be 0 if no files to move
def test_batch_reorganization_status_tracking(async_client, test_session):
"""Status should show overall + per-collection progress."""
# Test with a non-existent batch job
status = get_batch_reorganization_status('nonexistent-batch-job')
assert status['status'] == 'unknown'
assert 'error' in status
@pytest.mark.asyncio
async def test_batch_reorganization_no_collections_needing_reorganization(async_client, test_directory):
"""Execute batch reorganization when no collections need it."""
config = get_videos_downloader_config()
videos_dir = test_directory / 'videos'
channel_dir = videos_dir / 'SyncedChannel'
channel_dir.mkdir(parents=True, exist_ok=True)
with get_db_session(commit=True) as session:
collection = Collection(
name='SyncedChannel',
kind='channel',
directory=channel_dir,
file_format=config.file_name_format # Already synced
)
session.add(collection)
session.flush()
channel = Channel(name='SyncedChannel', collection_id=collection.id, directory=channel_dir)
session.add(channel)
session.commit()
with get_db_session() as session:
result = execute_batch_reorganization('channel', session)
# Should return empty batch job ID since nothing to do
assert result['batch_job_id'] == ''
assert result['collection_count'] == 0
# ============================================================================
# Tag Preservation Tests
# ============================================================================
@pytest.mark.asyncio
async def test_reorganize_preserves_tag_association(async_client, test_directory, video_factory, tag_factory,
test_tags_config):
"""FileGroup tags should be preserved after reorganization.
TagFile uses file_group_id (not paths), so tags should automatically
survive when FileGroup paths are updated during reorganization.
"""
from wrolpi.files.worker import file_worker, FileTask, FileTaskType
import asyncio
# Create channel collection with a specific directory
channel_dir = test_directory / 'videos' / 'test_channel'
channel_dir.mkdir(parents=True, exist_ok=True)
with get_db_session(commit=True) as session:
collection = Collection(
name='Test Channel',
kind='channel',
directory=channel_dir,
file_format='%(title)s.%(ext)s'
)
session.add(collection)
session.flush()
channel = Channel(name='Test Channel', collection_id=collection.id, directory=channel_dir)
session.add(channel)
session.commit()
channel_id = channel.id
# Create tag first
tag = await tag_factory('mytag')
# Create video in the channel
video = video_factory(channel_id=channel_id, title='test_video', with_video_file=True)
with get_db_session(commit=True) as session:
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
video_obj = session.get(Video, video.id)
video_obj.file_group.add_tag(session, tag.id)
fg_id = video_obj.file_group.id
old_primary_path = video_obj.file_group.primary_path
session.commit()
# Verify tag was added
with get_db_session() as session:
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
fg = session.get(FileGroup, fg_id)
assert fg.tag_names == ['mytag'], "Tag should be applied before reorganization"
# Create move mapping: rename files to a new pattern
new_stem = 'renamed_video'
new_primary_path = channel_dir / f'{new_stem}.mp4'
move_mappings = [(old_primary_path, new_primary_path)]
# Execute reorganization via file worker
task = FileTask(
task_type=FileTaskType.reorganize,
paths=[],
move_mappings=move_mappings,
job_id='test-reorganize-tags',
)
await file_worker.handle_reorganize(task)
await asyncio.sleep(0.1)
# Verify tags preserved after file move
with get_db_session() as session:
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
fg = session.get(FileGroup, fg_id)
assert fg.tag_names == ['mytag'], "Tag should be preserved after reorganization"
assert fg.primary_path == new_primary_path, "File should have moved"
@pytest.mark.asyncio
async def test_reorganize_updates_playlists_config(async_client, test_directory, video_factory, await_switches):
"""Reorganizing a playlist's file must write the new path to playlists.yaml, otherwise the next
re-import cannot find the file and drops the item."""
from copy import deepcopy
from wrolpi.collections.config import get_playlists_config
from wrolpi.files.worker import file_worker, FileTask, FileTaskType
playlists_config = get_playlists_config()
playlists_config._config = deepcopy(playlists_config.default_config)
channel_dir = test_directory / 'videos' / 'test_channel'
channel_dir.mkdir(parents=True, exist_ok=True)
with get_db_session(commit=True) as session:
collection = Collection(name='Test Channel', kind='channel', directory=channel_dir,
file_format='%(title)s.%(ext)s')
session.add(collection)
session.flush()
channel = Channel(name='Test Channel', collection_id=collection.id, directory=channel_dir)
session.add(channel)
session.commit()
channel_id = channel.id
video = video_factory(channel_id=channel_id, title='test_video', with_video_file=True)
with get_db_session(commit=True) as session:
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
file_group = session.get(Video, video.id).file_group
old_primary_path = file_group.primary_path
playlist = Collection(name='Watch', kind='playlist')
session.add(playlist)
session.flush([playlist])
playlist.add_file_group(file_group, session=session)
playlists_config.dump_config()
new_primary_path = channel_dir / 'renamed_video.mp4'
task = FileTask(
task_type=FileTaskType.reorganize,
paths=[],
move_mappings=[(old_primary_path, new_primary_path)],
job_id='test-reorganize-playlist',
)
await file_worker.handle_reorganize(task)
await await_switches()
file_refs = [i['file'] for p in playlists_config.playlists for i in p['items'] if 'file' in i]
assert file_refs == ['videos/test_channel/renamed_video.mp4']
playlists_config.import_config()
with get_db_session() as session:
playlist = session.query(Collection).filter_by(name='Watch', kind='playlist').one()
assert [i.file_group.primary_path for i in playlist.items] == [new_primary_path]
@pytest.mark.asyncio
async def test_reorganize_then_refresh_preserves_filegroup_id_and_tags(
async_client, test_directory, video_factory, tag_factory, test_tags_config, refresh_files):
"""A refresh after reorganize must keep the FileGroup id (and therefore tags).
Regression: _bulk_update_file_groups_reorganize left `stem` stale. Compare then
classified the old stem as deleted and the new filename as new, dropping tags.
"""
from wrolpi.files.worker import file_worker, FileTask, FileTaskType
import asyncio
channel_dir = test_directory / 'videos' / 'refresh_after_reorg'
channel_dir.mkdir(parents=True, exist_ok=True)
with get_db_session(commit=True) as session:
collection = Collection(
name='Refresh After Reorg',
kind='channel',
directory=channel_dir,
file_format='%(title)s.%(ext)s'
)
session.add(collection)
session.flush()
channel = Channel(name='Refresh After Reorg', collection_id=collection.id, directory=channel_dir)
session.add(channel)
session.commit()
channel_id = channel.id
tag = await tag_factory('keepme')
video = video_factory(channel_id=channel_id, title='old title', with_video_file=True)
with get_db_session(commit=True) as session:
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
video_obj = session.get(Video, video.id)
video_obj.file_group.add_tag(session, tag.id)
fg_id = video_obj.file_group.id
old_primary_path = video_obj.file_group.primary_path
session.commit()
new_primary_path = channel_dir / '2024-01-01 New Title.mp4'
await file_worker.handle_reorganize(FileTask(
task_type=FileTaskType.reorganize,
paths=[],
move_mappings=[(old_primary_path, new_primary_path)],
job_id='test-reorganize-then-refresh',
))
await asyncio.sleep(0.1)
await refresh_files([channel_dir])
with get_db_session() as session:
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
fg = session.get(FileGroup, fg_id)
assert fg is not None, 'FileGroup was dropped by refresh after reorganize'
assert fg.primary_path == new_primary_path
assert fg.stem == '2024-01-01 New Title'
assert fg.tag_names == ['keepme']
assert session.query(FileGroup).count() == 1
@pytest.mark.asyncio
async def test_reorganize_preserves_multiple_tags(async_client, test_directory, video_factory, tag_factory,
test_tags_config):
"""FileGroup with multiple tags should retain all tags after reorganization."""
from wrolpi.files.worker import file_worker, FileTask, FileTaskType
import asyncio
# Create channel collection
channel_dir = test_directory / 'videos' / 'multi_tag_channel'
channel_dir.mkdir(parents=True, exist_ok=True)
with get_db_session(commit=True) as session:
collection = Collection(
name='Multi Tag Channel',
kind='channel',
directory=channel_dir,
file_format='%(title)s.%(ext)s'
)
session.add(collection)
session.flush()
channel = Channel(name='Multi Tag Channel', collection_id=collection.id, directory=channel_dir)
session.add(channel)
session.commit()
channel_id = channel.id
# Create multiple tags
tag1 = await tag_factory('alpha')
tag2 = await tag_factory('beta')
tag3 = await tag_factory('gamma')
# Create video and add all tags
video = video_factory(channel_id=channel_id, title='multi_tagged_video', with_video_file=True)
with get_db_session(commit=True) as session:
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
video_obj = session.get(Video, video.id)
video_obj.file_group.add_tag(session, tag1.id)
video_obj.file_group.add_tag(session, tag2.id)
video_obj.file_group.add_tag(session, tag3.id)
fg_id = video_obj.file_group.id
old_primary_path = video_obj.file_group.primary_path
session.commit()
# Verify all tags were added
with get_db_session() as session:
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
fg = session.get(FileGroup, fg_id)
assert sorted(fg.tag_names) == ['alpha', 'beta', 'gamma'], "All tags should be applied"
# Create move mapping
new_primary_path = channel_dir / 'reorganized_multi_tag.mp4'
move_mappings = [(old_primary_path, new_primary_path)]
# Execute reorganization
task = FileTask(
task_type=FileTaskType.reorganize,
paths=[],
move_mappings=move_mappings,
job_id='test-reorganize-multi-tags',
)
await file_worker.handle_reorganize(task)
await asyncio.sleep(0.1)
# Verify all tags preserved
with get_db_session() as session:
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
fg = session.get(FileGroup, fg_id)
assert sorted(fg.tag_names) == ['alpha', 'beta', 'gamma'], \
"All tags should be preserved after reorganization"
assert fg.primary_path == new_primary_path, "File should have moved"
@pytest.mark.asyncio
async def test_reorganize_updates_tag_directory_links(async_client, test_directory, video_factory, tag_factory,
test_tags_config, await_switches):
"""Tag directory hardlinks should point to new file locations after reorganization.
After reorganization:
- Old hardlinks should be removed
- New hardlinks should exist at new paths
- Hardlinks should actually link to the moved files
"""
from wrolpi.files.worker import file_worker, FileTask, FileTaskType
from wrolpi.tags import sync_tags_directory, get_tags_directory
from wrolpi.common import is_hardlinked
import asyncio
# Create channel collection
channel_dir = test_directory / 'videos' / 'tag_link_channel'
channel_dir.mkdir(parents=True, exist_ok=True)
with get_db_session(commit=True) as session:
collection = Collection(
name='Tag Link Channel',
kind='channel',
directory=channel_dir,
file_format='%(title)s.%(ext)s'
)
session.add(collection)
session.flush()
channel = Channel(name='Tag Link Channel', collection_id=collection.id, directory=channel_dir)
session.add(channel)
session.commit()
channel_id = channel.id
# Create tag and video
tag = await tag_factory('linked_tag')
video = video_factory(channel_id=channel_id, title='link_test_video', with_video_file=True)
with get_db_session(commit=True) as session:
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
video_obj = session.get(Video, video.id)
video_obj.file_group.add_tag(session, tag.id)
fg_id = video_obj.file_group.id
old_primary_path = video_obj.file_group.primary_path
session.commit()
# Sync tags directory to create initial hardlinks
sync_tags_directory.activate_switch()
await await_switches()
# Verify initial hardlinks exist
tags_dir = get_tags_directory()
old_link_path = tags_dir / 'linked_tag' / old_primary_path.name
assert old_link_path.exists(), "Initial tag directory link should exist"
assert is_hardlinked(old_link_path), "Initial tag directory link should be a hardlink"
# Execute reorganization
new_primary_path = channel_dir / 'renamed_link_test.mp4'
move_mappings = [(old_primary_path, new_primary_path)]
task = FileTask(
task_type=FileTaskType.reorganize,
paths=[],
move_mappings=move_mappings,
job_id='test-reorganize-tag-links',
)
await file_worker.handle_reorganize(task)
await asyncio.sleep(0.1)
# Manually sync tags directory (this is the potential bug - reorganize may not call this)
sync_tags_directory.activate_switch()
await await_switches()
# Verify new hardlinks exist at new paths
new_link_path = tags_dir / 'linked_tag' / new_primary_path.name
assert new_link_path.exists(), f"New tag directory link should exist at {new_link_path}"
assert is_hardlinked(new_link_path), "New tag directory link should be a hardlink"
# Verify old hardlinks were removed
assert not old_link_path.exists(), "Old tag directory link should be removed"
# Verify the new file exists and is the same inode as the hardlink
assert new_primary_path.exists(), "New primary file should exist"
assert new_primary_path.stat().st_ino == new_link_path.stat().st_ino, \
"New file and new link should have the same inode"
@pytest.mark.asyncio
async def test_batch_reorganize_preserves_tags(async_client, test_directory, video_factory, tag_factory,
test_tags_config):
"""Batch reorganization should preserve tags across multiple collections."""
import asyncio
videos_dir = test_directory / 'videos'
videos_dir.mkdir(parents=True, exist_ok=True)
# Create two channels with different file formats
with get_db_session(commit=True) as session:
channel1_dir = videos_dir / 'BatchChannel1'
channel1_dir.mkdir(parents=True, exist_ok=True)
collection1 = Collection(
name='BatchChannel1',
kind='channel',
directory=channel1_dir,
file_format='%(old_format)s.%(ext)s'
)
session.add(collection1)
session.flush()
channel1 = Channel(name='BatchChannel1', collection_id=collection1.id, directory=channel1_dir)
session.add(channel1)
channel2_dir = videos_dir / 'BatchChannel2'
channel2_dir.mkdir(parents=True, exist_ok=True)
collection2 = Collection(
name='BatchChannel2',
kind='channel',
directory=channel2_dir,
file_format='%(old_format)s.%(ext)s'
)
session.add(collection2)
session.flush()
channel2 = Channel(name='BatchChannel2', collection_id=collection2.id, directory=channel2_dir)
session.add(channel2)
session.commit()
channel1_id = channel1.id
channel2_id = channel2.id
# Create tags
tag_a = await tag_factory('batch_tag_a')
tag_b = await tag_factory('batch_tag_b')
# Create videos in each channel with different tags
video1 = video_factory(channel_id=channel1_id, title='batch_video_1', with_video_file=True)
video2 = video_factory(channel_id=channel2_id, title='batch_video_2', with_video_file=True)
with get_db_session(commit=True) as session:
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
video1_obj = session.get(Video, video1.id)
video1_obj.file_group.add_tag(session, tag_a.id)
fg1_id = video1_obj.file_group.id
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
video2_obj = session.get(Video, video2.id)
video2_obj.file_group.add_tag(session, tag_b.id)
fg2_id = video2_obj.file_group.id
session.commit()
# Verify tags before batch reorganization
with get_db_session() as session:
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
fg1 = session.get(FileGroup, fg1_id)
fg2 = session.get(FileGroup, fg2_id)
assert fg1.tag_names == ['batch_tag_a']
assert fg2.tag_names == ['batch_tag_b']
# Execute batch reorganization
with get_db_session() as session:
result = execute_batch_reorganization('channel', session)
# Give time for batch operations
await asyncio.sleep(0.5)
# Verify tags preserved after batch reorganization
with get_db_session() as session:
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
fg1 = session.get(FileGroup, fg1_id)
fg2 = session.get(FileGroup, fg2_id)
assert fg1.tag_names == ['batch_tag_a'], \
"Tag on first channel's video should be preserved after batch reorganization"
assert fg2.tag_names == ['batch_tag_b'], \
"Tag on second channel's video should be preserved after batch reorganization"
# ============================================================================
# Partial Reorganization and Resume Tests
# ============================================================================
@pytest.mark.asyncio
async def test_reorganize_file_format_updated_after_completion(async_client, test_directory, video_factory):
"""Verify that file_format is NOT changed until after reorganization completes.
This is critical for resumability - if file_format is updated before files are moved,
the user cannot retry a failed reorganization because needs_reorganization would return False.
"""
from wrolpi.files.worker import file_worker, FileTask, FileTaskType
import asyncio
# Create channel collection with old file_format
channel_dir = test_directory / 'videos' / 'DeferredFormatChannel'
channel_dir.mkdir(parents=True, exist_ok=True)
old_format = '%(old_format)s.%(ext)s'
with get_db_session(commit=True) as session:
collection = Collection(
name='DeferredFormatChannel',
kind='channel',
directory=channel_dir,
file_format=old_format
)
session.add(collection)
session.flush()
channel = Channel(name='DeferredFormatChannel', collection_id=collection.id, directory=channel_dir)
session.add(channel)
session.commit()
channel_id = channel.id
collection_id = collection.id
# Create video
video = video_factory(channel_id=channel_id, title='format_test_video', with_video_file=True)
with get_db_session() as session:
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
video_obj = session.get(Video, video.id)
old_primary_path = video_obj.file_group.primary_path
# Get the current config format (what it should become after reorganization)
config = get_videos_downloader_config()
new_format = config.file_name_format
# Create move mapping
new_primary_path = channel_dir / 'renamed_format_test.mp4'
move_mappings = [(old_primary_path, new_primary_path)]
# Execute reorganization directly via handle_reorganize
task = FileTask(
task_type=FileTaskType.reorganize,
paths=[],
move_mappings=move_mappings,
collection_id=collection_id,
job_id='test-file-format-deferred',
pending_file_format=new_format, # Pass the pending format
)
# Before completion, file_format should still be old
with get_db_session() as session:
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
collection = session.get(Collection, collection_id)
assert collection.file_format == old_format, \
"file_format should NOT be updated before reorganization completes"
await file_worker.handle_reorganize(task)
await asyncio.sleep(0.1)
# After completion, file_format should now be updated
with get_db_session() as session:
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
collection = session.get(Collection, collection_id)
assert collection.file_format == new_format, \
"file_format should be updated after reorganization completes"
assert collection.needs_reorganization is False, \
"needs_reorganization should be False after successful reorganization"
@pytest.mark.asyncio
async def test_reorganize_can_retry_after_partial_failure(async_client, test_directory, video_factory):
"""Verify that reorganization can be retried after mid-process failure.
After partial failure:
- Some files may have been moved
- file_format should NOT have been updated (allowing retry)
- needs_reorganization should still return True
"""
import shutil
# Create channel collection
channel_dir = test_directory / 'videos' / 'PartialFailureChannel'
channel_dir.mkdir(parents=True, exist_ok=True)
old_format = '%(old_format)s.%(ext)s'
with get_db_session(commit=True) as session:
collection = Collection(
name='PartialFailureChannel',
kind='channel',
directory=channel_dir,
file_format=old_format
)
session.add(collection)
session.flush()
channel = Channel(name='PartialFailureChannel', collection_id=collection.id, directory=channel_dir)
session.add(channel)
session.commit()
channel_id = channel.id
collection_id = collection.id
# Create multiple videos
video1 = video_factory(channel_id=channel_id, title='partial_video_1', with_video_file=True)
video2 = video_factory(channel_id=channel_id, title='partial_video_2', with_video_file=True)
video3 = video_factory(channel_id=channel_id, title='partial_video_3', with_video_file=True)
with get_db_session() as session:
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
video1_obj = session.get(Video, video1.id)
video2_obj = session.get(Video, video2.id)
video3_obj = session.get(Video, video3.id)
path1 = video1_obj.file_group.primary_path
path2 = video2_obj.file_group.primary_path
path3 = video3_obj.file_group.primary_path
# Simulate partial failure: move video1 manually (as if reorganization partially completed)
dest1 = channel_dir / 'moved_partial_video_1.mp4'
shutil.move(str(path1), str(dest1))
# Verify setup - file moved but DB still points to old location
assert dest1.exists(), "Video 1 should be at destination"
assert not path1.exists(), "Video 1 should not be at source"
with get_db_session() as session:
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
video1_obj = session.get(Video, video1.id)
assert video1_obj.file_group.primary_path == path1, \
"DB should still point to old location (simulating partial failure before DB update)"
# Verify needs_reorganization is still True (file_format not updated)
with get_db_session() as session:
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
collection = session.get(Collection, collection_id)
assert collection.file_format == old_format, \
"file_format should still be old after partial failure"
assert collection.needs_reorganization is True, \
"needs_reorganization should still be True after partial failure"
@pytest.mark.asyncio
async def test_reorganize_handles_already_moved_files(async_client, test_directory, video_factory):
"""Verify graceful handling when files exist at destination but DB points to old location.
This is a recovery case - files were moved but DB update didn't complete (e.g., power loss).
The reorganization should:
- Detect that source doesn't exist but destination does
- Update DB to match destination paths
- Not error about missing source
"""
from wrolpi.files.worker import file_worker, FileTask, FileTaskType
import asyncio
import shutil
# Create channel collection
channel_dir = test_directory / 'videos' / 'RecoveryChannel'
channel_dir.mkdir(parents=True, exist_ok=True)
with get_db_session(commit=True) as session:
collection = Collection(
name='RecoveryChannel',
kind='channel',
directory=channel_dir,
file_format='%(old_format)s.%(ext)s'
)
session.add(collection)
session.flush()
channel = Channel(name='RecoveryChannel', collection_id=collection.id, directory=channel_dir)
session.add(channel)
session.commit()
channel_id = channel.id
collection_id = collection.id
# Create video
video = video_factory(channel_id=channel_id, title='recovery_video', with_video_file=True)
with get_db_session() as session:
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
video_obj = session.get(Video, video.id)
old_primary_path = video_obj.file_group.primary_path
fg_id = video_obj.file_group.id
# Record original file content for verification
original_content = old_primary_path.read_bytes()
# Manually move video to destination (simulating partial completion)
dest_path = channel_dir / 'recovered_video.mp4'
shutil.move(str(old_primary_path), str(dest_path))
# Verify setup
assert not old_primary_path.exists(), "Source should not exist"
assert dest_path.exists(), "Destination should exist (manually moved)"
with get_db_session() as session:
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
fg = session.get(FileGroup, fg_id)
assert fg.primary_path == old_primary_path, "DB should still point to source"
# Now run reorganization - it should recover by updating DB
move_mappings = [(old_primary_path, dest_path)]
config = get_videos_downloader_config()
task = FileTask(
task_type=FileTaskType.reorganize,
paths=[],
move_mappings=move_mappings,
collection_id=collection_id,
job_id='test-recovery',
pending_file_format=config.file_name_format,
)
await file_worker.handle_reorganize(task)
await asyncio.sleep(0.1)
# Verify recovery: DB should now point to destination
with get_db_session() as session:
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
fg = session.get(FileGroup, fg_id)
assert fg.primary_path == dest_path, \
f"DB should be updated to destination path after recovery. Got {fg.primary_path}"
assert fg.directory == dest_path.parent, \
"Directory should be updated to destination directory"
# Verify file content is intact
assert dest_path.read_bytes() == original_content, "File content should be preserved"
@pytest.mark.asyncio
async def test_reorganize_handles_mixed_state(async_client, test_directory, video_factory):
"""Verify reorganization handles a mix of filesystem states idempotently.
- Video 1: Manually move to destination (DB points to source, file at dest) - recovery case
- Video 2: Leave at source (normal case - DB and filesystem match)
- Video 3: Create copy at both source and destination (conflict case)
Expected results:
- Video 1: DB updated to dest path
- Video 2: File moved to dest, DB updated
- Video 3: Skipped with warning (both exist)
"""
from wrolpi.files.worker import file_worker, FileTask, FileTaskType
import asyncio
import shutil
# Create channel collection
channel_dir = test_directory / 'videos' / 'MixedStateChannel'
channel_dir.mkdir(parents=True, exist_ok=True)
with get_db_session(commit=True) as session:
collection = Collection(
name='MixedStateChannel',
kind='channel',
directory=channel_dir,
file_format='%(old_format)s.%(ext)s'
)
session.add(collection)
session.flush()
channel = Channel(name='MixedStateChannel', collection_id=collection.id, directory=channel_dir)
session.add(channel)
session.commit()
channel_id = channel.id
collection_id = collection.id
# Create 3 videos
video1 = video_factory(channel_id=channel_id, title='mixed_video_1', with_video_file=True)
video2 = video_factory(channel_id=channel_id, title='mixed_video_2', with_video_file=True)
video3 = video_factory(channel_id=channel_id, title='mixed_video_3', with_video_file=True)
with get_db_session() as session:
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
v1_obj = session.get(Video, video1.id)
v2_obj = session.get(Video, video2.id)
v3_obj = session.get(Video, video3.id)
path1 = v1_obj.file_group.primary_path
path2 = v2_obj.file_group.primary_path
path3 = v3_obj.file_group.primary_path
fg1_id = v1_obj.file_group.id
fg2_id = v2_obj.file_group.id
fg3_id = v3_obj.file_group.id
dest1 = channel_dir / 'reorganized_mixed_1.mp4'
dest2 = channel_dir / 'reorganized_mixed_2.mp4'
dest3 = channel_dir / 'reorganized_mixed_3.mp4'
# Setup mixed states
# Video 1: Move to dest (recovery case)
shutil.move(str(path1), str(dest1))
# Video 2: Leave at source (normal case)
# (no action needed)
# Video 3: Copy to both (conflict case)
shutil.copy(str(path3), str(dest3))
# Build move mappings
move_mappings = [
(path1, dest1),
(path2, dest2),
(path3, dest3),
]
config = get_videos_downloader_config()
task = FileTask(
task_type=FileTaskType.reorganize,
paths=[],
move_mappings=move_mappings,
collection_id=collection_id,
job_id='test-mixed-state',
pending_file_format=config.file_name_format,
)
await file_worker.handle_reorganize(task)
await asyncio.sleep(0.1)
with get_db_session() as session:
# Video 1: Should have DB updated to dest (recovery)
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
fg1 = session.get(FileGroup, fg1_id)
assert fg1.primary_path == dest1, \
f"Video 1 should have DB updated to dest path (recovery). Got {fg1.primary_path}"
# Video 2: Should be moved and DB updated (normal)
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
fg2 = session.get(FileGroup, fg2_id)
assert fg2.primary_path == dest2, \
f"Video 2 should be moved to dest. Got {fg2.primary_path}"
assert dest2.exists(), "Video 2 file should exist at destination"
assert not path2.exists(), "Video 2 file should not exist at source"
# Video 3: Should be skipped (both exist - conflict)
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
fg3 = session.get(FileGroup, fg3_id)
assert fg3.primary_path == path3, \
f"Video 3 should remain at source (conflict skipped). Got {fg3.primary_path}"
assert path3.exists(), "Video 3 should still exist at source"
assert dest3.exists(), "Video 3 should still exist at dest"
@pytest.mark.asyncio
async def test_batch_reorganize_can_resume_after_collection_failure(async_client, test_directory, video_factory):
"""Verify batch reorganization can resume after one collection fails.
- Create multiple channels with videos
- Batch reorganize, simulate failure on second collection
- Verify first collection was completed
- Verify second collection can be retried individually
- Verify file_format on failed collection was NOT updated
"""
# This test verifies that batch failures are recoverable
# The key assertion is that file_format is not updated for failed collections
from wrolpi.files.worker import file_worker
videos_dir = test_directory / 'videos'
videos_dir.mkdir(parents=True, exist_ok=True)
with get_db_session(commit=True) as session:
# Create two channels that need reorganization
channel1_dir = videos_dir / 'BatchResumeChannel1'
channel1_dir.mkdir(parents=True, exist_ok=True)
collection1 = Collection(
name='BatchResumeChannel1',
kind='channel',
directory=channel1_dir,
file_format='%(old_format)s.%(ext)s'
)
session.add(collection1)
session.flush()
channel1 = Channel(name='BatchResumeChannel1', collection_id=collection1.id, directory=channel1_dir)
session.add(channel1)
collection1_id = collection1.id
channel1_id = channel1.id
channel2_dir = videos_dir / 'BatchResumeChannel2'
channel2_dir.mkdir(parents=True, exist_ok=True)
collection2 = Collection(
name='BatchResumeChannel2',
kind='channel',
directory=channel2_dir,
file_format='%(old_format)s.%(ext)s'
)
session.add(collection2)
session.flush()
channel2 = Channel(name='BatchResumeChannel2', collection_id=collection2.id, directory=channel2_dir)
session.add(channel2)
collection2_id = collection2.id
session.commit()
# Create video in each channel
video1 = video_factory(channel_id=channel1_id, title='batch_resume_video_1', with_video_file=True)
# Verify both collections need reorganization
with get_db_session() as session:
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
c1 = session.get(Collection, collection1_id)
c2 = session.get(Collection, collection2_id)
assert c1.needs_reorganization is True
assert c2.needs_reorganization is True
# Execute batch reorganization for first collection only
# (simulating partial batch completion)
with get_db_session(commit=True) as session:
job_id = execute_reorganization(collection1_id, session)
if job_id:
# Process the reorganization
file_worker.transfer_queue()
await file_worker.process_queue()
# Verify first collection completed
with get_db_session() as session:
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
c1 = session.get(Collection, collection1_id)
c2 = session.get(Collection, collection2_id)
# First collection should be done (file_format updated)
# Note: This tests current behavior - may need adjustment based on implementation
config = get_videos_downloader_config()
assert c1.file_format == config.file_name_format, \
"First collection's file_format should be updated"
assert c1.needs_reorganization is False, \
"First collection should no longer need reorganization"
# Second collection should still need reorganization
assert c2.file_format == '%(old_format)s.%(ext)s', \
"Second collection's file_format should NOT be updated (batch didn't process it)"
assert c2.needs_reorganization is True, \
"Second collection should still need reorganization"
# ============================================================================
# Filename Conflict Detection Tests
# ============================================================================
@pytest.mark.asyncio
async def test_reorganize_skips_deleted_files_gracefully(async_client, test_directory, video_factory):
"""Reorganization should skip move_mappings for missing files, not fail.
Tests the handle_reorganize behavior when a move_mapping references a file
that no longer exists (neither source nor destination). This tests the
"neither exists" branch in handle_reorganize which should:
- Log a warning
- Skip the file
- Continue processing remaining files
- Complete successfully
Note: We also delete the Video/FileGroup records to prevent the video_modeler
from creating a new Video and failing during post-processing.
"""
from wrolpi.files.worker import file_worker, FileTask, FileTaskType
import asyncio
import os
# Create channel collection
channel_dir = test_directory / 'videos' / 'DeletedFilesChannel'
channel_dir.mkdir(parents=True, exist_ok=True)
with get_db_session(commit=True) as session:
collection = Collection(
name='DeletedFilesChannel',
kind='channel',
directory=channel_dir,
file_format='%(old_format)s.%(ext)s'
)
session.add(collection)
session.flush()
channel = Channel(name='DeletedFilesChannel', collection_id=collection.id, directory=channel_dir)
session.add(channel)
session.commit()
channel_id = channel.id
collection_id = collection.id
# Create 3 videos
video1 = video_factory(channel_id=channel_id, title='existing_video_1', with_video_file=True)
video2 = video_factory(channel_id=channel_id, title='deleted_video', with_video_file=True)
video3 = video_factory(channel_id=channel_id, title='existing_video_3', with_video_file=True)
with get_db_session() as session:
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
v1_obj = session.get(Video, video1.id)
v2_obj = session.get(Video, video2.id)
v3_obj = session.get(Video, video3.id)
path1 = v1_obj.file_group.primary_path
path2 = v2_obj.file_group.primary_path
path3 = v3_obj.file_group.primary_path
fg1_id = v1_obj.file_group.id
fg2_id = v2_obj.file_group.id
fg3_id = v3_obj.file_group.id
# Simulate user deleting video 2's file from filesystem
# Also delete the Video and FileGroup records to prevent post-processing failure
# (The video_modeler would otherwise create a new Video and try to ffprobe it)
os.remove(str(path2))
with get_db_session(commit=True) as session:
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
video_to_delete = session.get(Video, video2.id)
fg_to_delete = session.get(FileGroup, fg2_id)
session.delete(video_to_delete)
session.delete(fg_to_delete)
assert not path2.exists(), "Video 2 should be deleted from filesystem"
assert path1.exists(), "Video 1 should still exist"
assert path3.exists(), "Video 3 should still exist"
# Build move mappings for all 3 videos (as execute_reorganization would)
dest1 = channel_dir / 'reorganized_video_1.mp4'
dest2 = channel_dir / 'reorganized_deleted.mp4'
dest3 = channel_dir / 'reorganized_video_3.mp4'
move_mappings = [
(path1, dest1),
(path2, dest2), # This file doesn't exist - should be skipped
(path3, dest3),
]
config = get_videos_downloader_config()
task = FileTask(
task_type=FileTaskType.reorganize,
paths=[],
move_mappings=move_mappings,
collection_id=collection_id,
job_id='test-deleted-files',
pending_file_format=config.file_name_format,
)
# This should NOT raise an exception
await file_worker.handle_reorganize(task)
await asyncio.sleep(0.1)
# Verify results
with get_db_session() as session:
# Video 1: Should be moved and DB updated
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
fg1 = session.get(FileGroup, fg1_id)
assert fg1.primary_path == dest1, f"Video 1 should be moved to dest. Got {fg1.primary_path}"
assert dest1.exists(), "Video 1 file should exist at destination"
assert not path1.exists(), "Video 1 file should not exist at source"
# Video 2: Was deleted from both filesystem and DB, move_mapping was skipped
# (The reorganization logged a warning and continued)
assert not path2.exists(), "Video 2 file should not exist at source (was deleted)"
assert not dest2.exists(), "Video 2 file should not exist at destination (was deleted)"
# Video 3: Should be moved and DB updated
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
fg3 = session.get(FileGroup, fg3_id)
assert fg3.primary_path == dest3, f"Video 3 should be moved to dest. Got {fg3.primary_path}"
assert dest3.exists(), "Video 3 file should exist at destination"
assert not path3.exists(), "Video 3 file should not exist at source"
# Collection file_format should be updated (reorganization completed successfully)
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
collection = session.get(Collection, collection_id)
assert collection.file_format == config.file_name_format, \
"Collection file_format should be updated after successful reorganization"
@pytest.mark.parametrize("file_format,has_upload_date,has_source_id,has_uploader,should_produce_path", [
# Format requires upload_year - needs upload_date
("%(upload_year)s/%(title)s.%(ext)s", False, False, False, False),
("%(upload_year)s/%(title)s.%(ext)s", True, False, False, True),
# Format requires source_id - needs source_id
("%(id)s_%(title)s.%(ext)s", False, False, False, False),
("%(id)s_%(title)s.%(ext)s", False, True, False, True),
# Format requires uploader - video.channel.name provides fallback, so always True for videos with channel
("%(uploader)s_%(title)s.%(ext)s", False, False, False, True), # channel.name fallback
("%(uploader)s_%(title)s.%(ext)s", False, False, True, True),
# Format only needs title - always works
("%(title)s.%(ext)s", False, False, False, True),
("%(title)s.%(ext)s", True, True, True, True),
# Complex format - needs all metadata (upload_date, source_id, uploader)
("%(upload_year)s/%(uploader)s_%(upload_date)s_%(id)s_%(title)s.%(ext)s", False, False, False, False),
("%(upload_year)s/%(uploader)s_%(upload_date)s_%(id)s_%(title)s.%(ext)s", True, True, True, True),
# Missing some but has others - still fails if format requires the missing one
("%(upload_year)s/%(uploader)s_%(title)s.%(ext)s", False, True, True, False), # missing upload_date
("%(upload_year)s/%(uploader)s_%(title)s.%(ext)s", True, False, False, True), # channel.name fallback for uploader
("%(upload_year)s/%(uploader)s_%(title)s.%(ext)s", True, False, True, True), # has both required
])
def test_reorganize_skips_videos_missing_required_metadata(
test_session, test_directory, video_factory,
file_format, has_upload_date, has_source_id, has_uploader, should_produce_path
):
"""Videos should only be skipped when missing metadata that the format requires.
This test verifies that _compute_new_path_for_video correctly determines
whether a video has sufficient metadata based on the configured format string.
"""
from datetime import datetime, timezone
from wrolpi.collections.reorganize import _compute_new_path_for_video
# Create channel collection
channel_dir = test_directory / 'videos' / 'MetadataTestChannel'
channel_dir.mkdir(parents=True, exist_ok=True)
collection = Collection(
name='MetadataTestChannel',
kind='channel',
directory=channel_dir,
)
test_session.add(collection)
test_session.flush()
channel = Channel(name='MetadataTestChannel', collection_id=collection.id, directory=channel_dir)
test_session.add(channel)
test_session.commit()
channel_id = channel.id
# Build info_json dict if we need uploader or source_id from it
info_json = {}
if has_uploader:
info_json['uploader'] = 'TestUploader'
if has_source_id:
info_json['id'] = 'abc123'
# Create video with controlled metadata
# with_info_json creates the actual .info.json file on disk
video = video_factory(
channel_id=channel_id,
title='Test Video',
with_video_file=True,
with_info_json=info_json if info_json else None,
)
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
video_obj = test_session.get(Video, video.id)
# Set source_id directly on the Video model (separate from info_json)
video_obj.source_id = 'abc123' if has_source_id else None
video_obj.file_group.published_datetime = datetime(2024, 6, 15, tzinfo=timezone.utc) if has_upload_date else None
test_session.commit()
test_session.refresh(video_obj)
# Call _compute_new_path_for_video with explicit file_format
new_path = _compute_new_path_for_video(video_obj, channel_dir, file_format=file_format)
if should_produce_path:
assert new_path is not None, (
f"Format '{file_format}' with upload_date={has_upload_date}, "
f"source_id={has_source_id}, uploader={has_uploader} should produce a path"
)
# Verify the path doesn't contain empty segments
path_str = str(new_path.relative_to(channel_dir))
assert not path_str.startswith('/'), f"Path should not start with /: {path_str}"
else:
assert new_path is None, (
f"Format '{file_format}' with upload_date={has_upload_date}, "
f"source_id={has_source_id}, uploader={has_uploader} should return None, "
f"but got: {new_path}"
)
@pytest.mark.parametrize("file_format,has_url,should_produce_path", [
# Format requires domain - needs URL
("%(domain)s/%(title)s.%(ext)s", False, False),
("%(domain)s/%(title)s.%(ext)s", True, True),
# Format only needs title/download_date - always works
("%(title)s.%(ext)s", False, True),
("%(title)s.%(ext)s", True, True),
("%(download_datetime)s_%(title)s.%(ext)s", False, True),
("%(download_datetime)s_%(title)s.%(ext)s", True, True),
# Complex format with domain
("%(download_year)s/%(domain)s_%(title)s.%(ext)s", False, False),
("%(download_year)s/%(domain)s_%(title)s.%(ext)s", True, True),
# Format without domain - always works
("%(download_year)s/%(download_month)s/%(title)s.%(ext)s", False, True),
])
def test_reorganize_skips_archives_missing_required_metadata(
test_session, test_directory, archive_factory,
file_format, has_url, should_produce_path
):
"""Archives should only be skipped when missing metadata that the format requires.
Archives have robust fallbacks for most variables (dates fallback to now(),
title to 'untitled'). Only domain can be empty when URL is missing.
"""
from wrolpi.collections.reorganize import _compute_new_path_for_archive
# Create domain collection
domain_dir = test_directory / 'archive' / 'test.com'
domain_dir.mkdir(parents=True, exist_ok=True)
collection = Collection(
name='test.com',
kind='domain',
directory=domain_dir,
)
test_session.add(collection)
test_session.commit()
# Create archive with controlled metadata
archive = archive_factory(
domain='test.com',
url='https://test.com/article' if has_url else None,
title='Test Article',
)
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
archive_obj = test_session.get(Archive, archive.id)
# Clear URL if testing without it
if not has_url:
archive_obj.file_group.url = None
test_session.commit()
test_session.refresh(archive_obj)
# Call _compute_new_path_for_archive with explicit file_format
new_path = _compute_new_path_for_archive(archive_obj, domain_dir, file_format=file_format)
if should_produce_path:
assert new_path is not None, (
f"Format '{file_format}' with url={has_url} should produce a path"
)
else:
assert new_path is None, (
f"Format '{file_format}' with url={has_url} should return None, "
f"but got: {new_path}"
)
# ============================================================================
# Partial Reorganization Tests (Individual Collection Reorganization)
# ============================================================================
@pytest.mark.asyncio
async def test_domain_reorganization_moves_files_from_root_to_year_subdirectory(
async_client, test_directory, archive_factory
):
"""Archives in collection root should be moved to year subdirectories.
Scenario: Batch reorganization completed but missed some files.
- collection.file_format matches config (needs_reorganization=False)
- Some archives are in root: domain.com/2000-01-01_article.html
- Format expects: domain.com/2000/2000-01-01_article.html
- Reorganization should move files from root to year subdirectory
"""
from wrolpi.files.worker import file_worker
# Create domain collection with year-based format
domain_dir = test_directory / 'archive' / 'test.com'
domain_dir.mkdir(parents=True, exist_ok=True)
year_format = '%(download_year)s/%(download_datetime)s_%(title)s.%(ext)s'
# Temporarily change config to use year format
config = get_archive_downloader_config()
original_format = config._config['file_name_format']
config._config['file_name_format'] = year_format
try:
with get_db_session(commit=True) as session:
collection = Collection(
name='test.com',
kind='domain',
directory=domain_dir,
file_format=year_format, # Matches expected format (appears organized)
)
session.add(collection)
session.commit()
collection_id = collection.id
# Create archive using factory - files will be in root (no year subdir)
# Factory creates files with format: %(download_datetime)s_%(title)s.%(ext)s (NOT year)
archive = archive_factory(domain='test.com', url='https://test.com/article', title='Test Article')
with get_db_session(commit=True) as session:
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
archive_obj = session.get(Archive, archive.id)
archive_obj.collection_id = collection_id
session.commit()
# Verify: collection appears organized but files are in wrong place
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
collection = session.get(Collection, collection_id)
assert collection.file_format == year_format, "Collection format should match config"
assert collection.needs_reorganization is False, \
"Collection should appear organized (format matches config)"
old_path = archive_obj.file_group.primary_path
assert old_path.parent == domain_dir, \
f"Archive should be in root, not year subdir. Got: {old_path.parent}"
# Get preview - should detect files need moving
with get_db_session() as session:
preview = get_reorganization_preview(collection_id, session)
assert preview.files_needing_move > 0, \
f"Should detect files need moving. Got: {preview.files_needing_move}"
# Execute reorganization
with get_db_session(commit=True) as session:
job_id = execute_reorganization(collection_id, session)
assert job_id, "Should return job_id for files to move"
# Process the reorganization
file_worker.transfer_queue()
await file_worker.process_queue()
# Verify: files moved to year subdirectory
with get_db_session() as session:
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
archive_obj = session.get(Archive, archive.id)
new_path = archive_obj.file_group.primary_path
assert '/2000/' in str(new_path), f"File should be in year subdirectory. Got: {new_path}"
assert new_path.exists(), "New file should exist"
finally:
config._config['file_name_format'] = original_format
@pytest.mark.asyncio
async def test_channel_reorganization_moves_files_from_root_to_year_subdirectory(
async_client, test_directory, video_factory
):
"""Videos in collection root should be moved to year subdirectories.
Scenario: Files are in the wrong place and need to be moved.
- Some videos are in root: channel/title.mp4
- Format expects: channel/2024/title.mp4
- Reorganization should move files from root to year subdirectory
"""
from datetime import datetime, timezone
from wrolpi.files.worker import file_worker
# Create channel collection with year-based format
channel_dir = test_directory / 'videos' / 'TestChannel'
channel_dir.mkdir(parents=True, exist_ok=True)
year_format = '%(upload_year)s/%(title)s.%(ext)s'
# Temporarily change config to use year format (videos config is nested)
# Manager dict requires reassigning the entire nested dict for changes to propagate
config = get_videos_downloader_config()
original_format = config._config['yt_dlp_options']['file_name_format']
yt_dlp_options = dict(config._config['yt_dlp_options'])
yt_dlp_options['file_name_format'] = year_format
config._config['yt_dlp_options'] = yt_dlp_options
try:
with get_db_session(commit=True) as session:
collection = Collection(
name='TestChannel',
kind='channel',
directory=channel_dir,
file_format=year_format,
)
session.add(collection)
session.flush()
channel = Channel(name='TestChannel', collection_id=collection.id, directory=channel_dir)
session.add(channel)
session.commit()
collection_id = collection.id
channel_id = channel.id
# Create video using factory - files will be in root (no year subdir)
video = video_factory(
channel_id=channel_id,
title='test_video',
with_video_file=True,
with_info_json={'uploader': 'Tester', 'id': 'abc123'},
upload_date=datetime(2024, 6, 15, tzinfo=timezone.utc),
)
with get_db_session(commit=True) as session:
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
video_obj = session.get(Video, video.id)
old_path = video_obj.file_group.primary_path
assert old_path.parent == channel_dir, \
f"Video should be in root, not year subdir. Got: {old_path.parent}"
# Get preview - should detect files need moving
with get_db_session() as session:
preview = get_reorganization_preview(collection_id, session)
assert preview.files_needing_move > 0, \
f"Should detect files need moving. Got: {preview.files_needing_move}"
# Execute reorganization
with get_db_session(commit=True) as session:
job_id = execute_reorganization(collection_id, session)
assert job_id, "Should return job_id for files to move"
# Process the reorganization
file_worker.transfer_queue()
await file_worker.process_queue()
# Verify: files moved to year subdirectory
with get_db_session() as session:
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
video_obj = session.get(Video, video.id)
new_path = video_obj.file_group.primary_path
assert '/2024/' in str(new_path), f"File should be in year subdirectory. Got: {new_path}"
assert new_path.exists(), "New file should exist"
finally:
yt_dlp_options = dict(config._config['yt_dlp_options'])
yt_dlp_options['file_name_format'] = original_format
config._config['yt_dlp_options'] = yt_dlp_options
@pytest.mark.asyncio
async def test_channel_reorganization_preserves_root_level_files(
async_client, test_directory, video_factory
):
"""Root-level files (like channel info.json and images) should NOT be moved during reorganization.
This is intentional behavior - only Video files tracked in FileGroups are reorganized.
Channel metadata files at the collection root should remain in place.
"""
from datetime import datetime, timezone
from wrolpi.files.worker import file_worker
# Create channel collection with year-based format
channel_dir = test_directory / 'videos' / 'TestChannel'
channel_dir.mkdir(parents=True, exist_ok=True)
year_format = '%(upload_year)s/%(title)s.%(ext)s'
# Create root-level files that should NOT be moved
root_json = channel_dir / 'TestChannel.info.json'
root_json.write_text('{"channel": "test"}')
root_image = channel_dir / 'channel_banner.jpg'
root_image.write_bytes(b'\xff\xd8\xff\xe0') # Minimal JPEG header
# Temporarily change config to use year format
config = get_videos_downloader_config()
original_format = config._config['yt_dlp_options']['file_name_format']
yt_dlp_options = dict(config._config['yt_dlp_options'])
yt_dlp_options['file_name_format'] = year_format
config._config['yt_dlp_options'] = yt_dlp_options
try:
with get_db_session(commit=True) as session:
collection = Collection(
name='TestChannel',
kind='channel',
directory=channel_dir,
file_format=year_format,
)
session.add(collection)
session.flush()
channel = Channel(name='TestChannel', collection_id=collection.id, directory=channel_dir)
session.add(channel)
session.commit()
collection_id = collection.id
channel_id = channel.id
# Create videos using factory - files will be in root initially
video1 = video_factory(
channel_id=channel_id,
title='video_one',
with_video_file=True,
upload_date=datetime(2024, 6, 15, tzinfo=timezone.utc),
)
video2 = video_factory(
channel_id=channel_id,
title='video_two',
with_video_file=True,
upload_date=datetime(2024, 8, 20, tzinfo=timezone.utc),
)
# Verify initial state: videos in root, root files exist
with get_db_session() as session:
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
v1 = session.get(Video, video1.id)
v2 = session.get(Video, video2.id)
assert v1.file_group.primary_path.parent == channel_dir
assert v2.file_group.primary_path.parent == channel_dir
assert root_json.exists(), "Root JSON should exist before reorganization"
assert root_image.exists(), "Root image should exist before reorganization"
# Execute reorganization
with get_db_session(commit=True) as session:
job_id = execute_reorganization(collection_id, session)
assert job_id, "Should return job_id for files to move"
# Process the reorganization
file_worker.transfer_queue()
await file_worker.process_queue()
# Verify: videos moved to year subdirectory
with get_db_session() as session:
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
v1 = session.get(Video, video1.id)
v2 = session.get(Video, video2.id)
assert '/2024/' in str(v1.file_group.primary_path), "Video 1 should be in year subdirectory"
assert '/2024/' in str(v2.file_group.primary_path), "Video 2 should be in year subdirectory"
assert v1.file_group.primary_path.exists()
assert v2.file_group.primary_path.exists()
# Verify: root-level files were NOT moved (this is the key assertion)
assert root_json.exists(), "Root JSON should still exist at original location after reorganization"
assert root_image.exists(), "Root image should still exist at original location after reorganization"
assert root_json.read_text() == '{"channel": "test"}', "Root JSON content should be unchanged"
finally:
yt_dlp_options = dict(config._config['yt_dlp_options'])
yt_dlp_options['file_name_format'] = original_format
config._config['yt_dlp_options'] = yt_dlp_options
Creating browser extension to facilitate starting downloads. Chrome/Brave and Firefox supported. The extension is hosted in a sibling repo and is distributed off-store. Each WROLPi release ships the prebuilt artifacts under wrolpi/static/extensions/ and serves them at /api/extensions/<file>: - wrolpi-chrome.zip Load-Unpacked sideload for Chrome / Brave / Edge - wrolpi-firefox.xpi Mozilla-signed (unlisted self-distribution), one-click install in any Firefox - versions.json Per-browser version metadata for the install page Backend (modules/archive/api.py, wrolpi/root_api.py): - POST /api/archive/upload accepts an optional repeated `tagNames` multipart form field; the upload switch handler applies tags to the new FileGroup via FileGroup.set_tags(). Unknown tag names are auto- created inside a per-name SAVEPOINT so concurrent inserts on the same novel name don't abort the transaction (Tag.name has unique=True). - GET /api/extensions returns metadata (per-file availability + size + versions.json contents) for the install page. - GET /api/extensions/<filename> serves the binary, whitelisted by filename. Firefox .xpi is served as application/x-xpinstall WITHOUT a Content-Disposition: attachment header, so Firefox triggers its add-on install handler instead of saving the file. Frontend (app/src/): - New /admin/extension page (linked from AdminRoute) detects the user's browser via navigator.userAgent and renders an install card per Firefox / Chromium / Other. Pulls metadata from /api/extensions to show file size and version, and gracefully reports 'Not yet available' when binaries are missing. - Dashboard's Getters component supports a deep link from the extension's 'Configure Download' button: /?downloader=<archive|video|...>&download_url=<u1>&download_url=<u2> On mount, the matching downloader form auto-opens with URLs pre- filled. Query params are cleared on close so a refresh doesn't re-open the modal. - DownloadMenu and the per-downloader forms accept an `initialUrls` seed via the existing `download` prop pattern, joined with newlines for the `urls` textarea (and first URL for single-URL forms). Behavior is fully backwards compatible: pre-existing API callers can still POST to /api/archive/upload without `tagNames`, and the DashboardPage modal continues to open via the existing button click when no deep-link query params are present.
2026-05-02 14:31:54 -06:00
async def test_preview_computes_actual_moves(async_client, test_session, test_directory, archive_factory):
"""Preview should compute actual files needing move by building move mappings.
This test verifies that:
- Preview correctly identifies files in wrong location
- After reorganization, preview shows 0 files needing move
Creating browser extension to facilitate starting downloads. Chrome/Brave and Firefox supported. The extension is hosted in a sibling repo and is distributed off-store. Each WROLPi release ships the prebuilt artifacts under wrolpi/static/extensions/ and serves them at /api/extensions/<file>: - wrolpi-chrome.zip Load-Unpacked sideload for Chrome / Brave / Edge - wrolpi-firefox.xpi Mozilla-signed (unlisted self-distribution), one-click install in any Firefox - versions.json Per-browser version metadata for the install page Backend (modules/archive/api.py, wrolpi/root_api.py): - POST /api/archive/upload accepts an optional repeated `tagNames` multipart form field; the upload switch handler applies tags to the new FileGroup via FileGroup.set_tags(). Unknown tag names are auto- created inside a per-name SAVEPOINT so concurrent inserts on the same novel name don't abort the transaction (Tag.name has unique=True). - GET /api/extensions returns metadata (per-file availability + size + versions.json contents) for the install page. - GET /api/extensions/<filename> serves the binary, whitelisted by filename. Firefox .xpi is served as application/x-xpinstall WITHOUT a Content-Disposition: attachment header, so Firefox triggers its add-on install handler instead of saving the file. Frontend (app/src/): - New /admin/extension page (linked from AdminRoute) detects the user's browser via navigator.userAgent and renders an install card per Firefox / Chromium / Other. Pulls metadata from /api/extensions to show file size and version, and gracefully reports 'Not yet available' when binaries are missing. - Dashboard's Getters component supports a deep link from the extension's 'Configure Download' button: /?downloader=<archive|video|...>&download_url=<u1>&download_url=<u2> On mount, the matching downloader form auto-opens with URLs pre- filled. Query params are cleared on close so a refresh doesn't re-open the modal. - DownloadMenu and the per-downloader forms accept an `initialUrls` seed via the existing `download` prop pattern, joined with newlines for the `urls` textarea (and first URL for single-URL forms). Behavior is fully backwards compatible: pre-existing API callers can still POST to /api/archive/upload without `tagNames`, and the DashboardPage modal continues to open via the existing button click when no deep-link query params are present.
2026-05-02 14:31:54 -06:00
Uses the `async_client` fixture so the Sanic app's shared_ctx (including
file_worker_jobs) is initialized before file_worker.process_queue() runs.
Without it, file_worker._jobs raises AttributeError on the missing key.
"""
# Create domain collection with a year-based format (different from default)
domain_dir = test_directory / 'archive' / 'exactcount.com'
domain_dir.mkdir(parents=True, exist_ok=True)
year_format = '%(download_year)s/%(download_datetime)s_%(title)s.%(ext)s'
# Temporarily change config to use year format
config = get_archive_downloader_config()
original_format = config._config['file_name_format']
config._config['file_name_format'] = year_format
try:
collection = Collection(
name='exactcount.com',
kind='domain',
directory=domain_dir,
file_format=year_format, # Matches config
)
test_session.add(collection)
test_session.commit()
collection_id = collection.id
# Create archive using factory - files will be in root (need to move to year subdir)
archive = archive_factory(domain='exactcount.com', url='https://exactcount.com/article', title='Test')
with get_db_session(commit=True) as session:
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
archive_obj = session.get(Archive, archive.id)
archive_obj.collection_id = collection_id
session.commit()
# Preview should detect that the file needs moving (in wrong location)
with get_db_session() as session:
preview = get_reorganization_preview(collection_id, session)
# Files are in root, but should be in year subdir, so 1 file needs moving
assert preview.files_needing_move == 1, \
f"File in wrong location should need moving. Got: {preview.files_needing_move}"
assert preview.total_files == 1
# Now execute the reorganization to move files to correct location
from wrolpi.files.worker import file_worker
with get_db_session(commit=True) as session:
job_id = execute_reorganization(collection_id, session)
assert job_id, "Should return job_id"
file_worker.transfer_queue()
Creating browser extension to facilitate starting downloads. Chrome/Brave and Firefox supported. The extension is hosted in a sibling repo and is distributed off-store. Each WROLPi release ships the prebuilt artifacts under wrolpi/static/extensions/ and serves them at /api/extensions/<file>: - wrolpi-chrome.zip Load-Unpacked sideload for Chrome / Brave / Edge - wrolpi-firefox.xpi Mozilla-signed (unlisted self-distribution), one-click install in any Firefox - versions.json Per-browser version metadata for the install page Backend (modules/archive/api.py, wrolpi/root_api.py): - POST /api/archive/upload accepts an optional repeated `tagNames` multipart form field; the upload switch handler applies tags to the new FileGroup via FileGroup.set_tags(). Unknown tag names are auto- created inside a per-name SAVEPOINT so concurrent inserts on the same novel name don't abort the transaction (Tag.name has unique=True). - GET /api/extensions returns metadata (per-file availability + size + versions.json contents) for the install page. - GET /api/extensions/<filename> serves the binary, whitelisted by filename. Firefox .xpi is served as application/x-xpinstall WITHOUT a Content-Disposition: attachment header, so Firefox triggers its add-on install handler instead of saving the file. Frontend (app/src/): - New /admin/extension page (linked from AdminRoute) detects the user's browser via navigator.userAgent and renders an install card per Firefox / Chromium / Other. Pulls metadata from /api/extensions to show file size and version, and gracefully reports 'Not yet available' when binaries are missing. - Dashboard's Getters component supports a deep link from the extension's 'Configure Download' button: /?downloader=<archive|video|...>&download_url=<u1>&download_url=<u2> On mount, the matching downloader form auto-opens with URLs pre- filled. Query params are cleared on close so a refresh doesn't re-open the modal. - DownloadMenu and the per-downloader forms accept an `initialUrls` seed via the existing `download` prop pattern, joined with newlines for the `urls` textarea (and first URL for single-URL forms). Behavior is fully backwards compatible: pre-existing API callers can still POST to /api/archive/upload without `tagNames`, and the DashboardPage modal continues to open via the existing button click when no deep-link query params are present.
2026-05-02 14:31:54 -06:00
await file_worker.process_queue()
# After reorganization, should show 0 files needing move
with get_db_session() as session:
preview_after = get_reorganization_preview(collection_id, session)
assert preview_after.files_needing_move == 0, \
f"After reorganization, no files should need moving. Got: {preview_after.files_needing_move}"
finally:
config._config['file_name_format'] = original_format
@pytest.mark.asyncio
async def test_reorganize_fails_on_filename_conflicts(async_client, test_directory, video_factory):
"""Reorganization should fail early when two videos would have the same destination filename."""
from datetime import datetime, timezone
# Create channel collection
channel_dir = test_directory / 'videos' / 'ConflictChannel'
channel_dir.mkdir(parents=True, exist_ok=True)
with get_db_session(commit=True) as session:
collection = Collection(
name='ConflictChannel',
kind='channel',
directory=channel_dir,
file_format='%(old_format)s.%(ext)s' # Needs reorganization
)
session.add(collection)
session.flush()
channel = Channel(name='ConflictChannel', collection_id=collection.id, directory=channel_dir)
session.add(channel)
session.commit()
channel_id = channel.id
collection_id = collection.id
# Create two videos with DIFFERENT initial filenames but IDENTICAL destination metadata
# Both videos will have the same uploader, upload_date, source_id, and title
# This should produce a filename conflict
same_date = datetime(2024, 1, 15, tzinfo=timezone.utc)
same_source_id = 'duplicate_id'
video1 = video_factory(
channel_id=channel_id,
title='video_one', # Different initial filename
upload_date=same_date,
with_video_file=True,
with_info_json={'uploader': 'TestUploader', 'id': same_source_id}
)
video2 = video_factory(
channel_id=channel_id,
title='video_two', # Different initial filename
upload_date=same_date,
with_video_file=True,
with_info_json={'uploader': 'TestUploader', 'id': same_source_id}
)
# Update both videos to have the same title and source_id
# This makes them produce the same destination filename
with get_db_session(commit=True) as session:
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
v1 = session.get(Video, video1.id)
v2 = session.get(Video, video2.id)
v1.file_group.title = 'Duplicate Title'
v2.file_group.title = 'Duplicate Title'
v1.source_id = same_source_id
v2.source_id = same_source_id
# Attempting to reorganize should fail with conflict error
with pytest.raises(ReorganizationConflict) as exc_info:
with get_db_session() as session:
execute_reorganization(collection_id, session)
# Error message should mention the conflict
assert 'conflict' in str(exc_info.value).lower()
assert 'Duplicate Title' in str(exc_info.value)
@pytest.mark.asyncio
async def test_reorganize_preview_returns_conflict_details(async_client, test_directory, video_factory):
"""Preview should return detailed conflict information for UI display."""
from datetime import datetime, timezone
# Create channel collection
channel_dir = test_directory / 'videos' / 'ConflictPreviewChannel'
channel_dir.mkdir(parents=True, exist_ok=True)
with get_db_session(commit=True) as session:
collection = Collection(
name='ConflictPreviewChannel',
kind='channel',
directory=channel_dir,
file_format='%(old_format)s.%(ext)s' # Needs reorganization
)
session.add(collection)
session.flush()
channel = Channel(name='ConflictPreviewChannel', collection_id=collection.id, directory=channel_dir)
session.add(channel)
session.commit()
channel_id = channel.id
collection_id = collection.id
# Create two videos with DIFFERENT initial filenames but IDENTICAL destination metadata
same_date = datetime(2024, 1, 15, tzinfo=timezone.utc)
same_source_id = 'preview_duplicate_id'
video1 = video_factory(
channel_id=channel_id,
title='video_one',
upload_date=same_date,
with_video_file=True,
with_info_json={'uploader': 'TestUploader', 'id': same_source_id}
)
video2 = video_factory(
channel_id=channel_id,
title='video_two',
upload_date=same_date,
with_video_file=True,
with_info_json={'uploader': 'TestUploader', 'id': same_source_id}
)
# Update both videos to have the same title and source_id
with get_db_session(commit=True) as session:
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
v1 = session.get(Video, video1.id)
v2 = session.get(Video, video2.id)
v1.file_group.title = 'Duplicate Preview Title'
v2.file_group.title = 'Duplicate Preview Title'
v1.source_id = same_source_id
v2.source_id = same_source_id
# Get preview - should show conflicts instead of raising an exception
with get_db_session() as session:
preview = get_reorganization_preview(collection_id, session)
# Verify preview has conflict information
assert preview.has_conflicts is True, "Preview should indicate conflicts exist"
assert len(preview.conflicts) == 1, f"Should have 1 conflict, got {len(preview.conflicts)}"
conflict = preview.conflicts[0]
assert 'destination_path' in conflict
assert 'conflicting_files' in conflict
assert len(conflict['conflicting_files']) == 2, "Should have 2 conflicting files"
# Verify file details are included
for file_info in conflict['conflicting_files']:
assert 'file_group_id' in file_info
assert 'current_path' in file_info
assert 'title' in file_info
assert 'model_type' in file_info
assert file_info['model_type'] == 'video'
assert file_info['source_id'] == same_source_id
@pytest.mark.asyncio
async def test_reorganize_preview_no_conflicts(async_client, test_directory, video_factory):
"""Preview should show no conflicts when files have unique destinations."""
from datetime import datetime, timezone
# Create channel collection
channel_dir = test_directory / 'videos' / 'NoConflictChannel'
channel_dir.mkdir(parents=True, exist_ok=True)
with get_db_session(commit=True) as session:
collection = Collection(
name='NoConflictChannel',
kind='channel',
directory=channel_dir,
file_format='%(old_format)s.%(ext)s'
)
session.add(collection)
session.flush()
channel = Channel(name='NoConflictChannel', collection_id=collection.id, directory=channel_dir)
session.add(channel)
session.commit()
channel_id = channel.id
collection_id = collection.id
# Create two videos with DIFFERENT titles (unique destinations)
video1 = video_factory(
channel_id=channel_id,
title='Unique Title One',
upload_date=datetime(2024, 1, 15, tzinfo=timezone.utc),
with_video_file=True,
with_info_json={'uploader': 'TestUploader', 'id': 'unique_id_1'}
)
video2 = video_factory(
channel_id=channel_id,
title='Unique Title Two',
upload_date=datetime(2024, 1, 16, tzinfo=timezone.utc),
with_video_file=True,
with_info_json={'uploader': 'TestUploader', 'id': 'unique_id_2'}
)
# Get preview - should show no conflicts
with get_db_session() as session:
preview = get_reorganization_preview(collection_id, session)
assert preview.has_conflicts is False, "Preview should indicate no conflicts"
assert preview.conflicts == [], "Conflicts list should be empty"
@pytest.mark.asyncio
async def test_conflict_preview_includes_quality_rank(async_client, test_directory, video_factory):
"""Conflict preview should include quality_rank for videos based on metadata completeness.
Videos with more metadata (info_json, captions, poster) should rank higher.
Conflicting files should be sorted by quality_rank (highest first).
"""
from datetime import datetime, timezone
# Create channel collection
channel_dir = test_directory / 'videos' / 'QualityRankChannel'
channel_dir.mkdir(parents=True, exist_ok=True)
with get_db_session(commit=True) as session:
collection = Collection(
name='QualityRankChannel',
kind='channel',
directory=channel_dir,
file_format='%(old_format)s.%(ext)s' # Needs reorganization
)
session.add(collection)
session.flush()
channel = Channel(name='QualityRankChannel', collection_id=collection.id, directory=channel_dir)
session.add(channel)
session.commit()
channel_id = channel.id
collection_id = collection.id
same_date = datetime(2024, 1, 15, tzinfo=timezone.utc)
same_source_id = 'quality_rank_test_id'
# video1 - MINIMAL metadata (lower rank expected)
video1 = video_factory(
channel_id=channel_id,
title='minimal_video',
upload_date=same_date,
with_video_file=True,
# No info_json, no poster, no captions
)
# video2 - RICH metadata (higher rank expected)
video2 = video_factory(
channel_id=channel_id,
title='rich_video',
upload_date=same_date,
with_video_file=True,
with_info_json={'uploader': 'TestUploader', 'id': same_source_id, 'title': 'Rich Video Title'},
with_poster_ext='jpg',
with_caption_file=True,
)
# Make both videos conflict (same title, same source_id → same destination)
with get_db_session(commit=True) as session:
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
v1 = session.get(Video, video1.id)
v2 = session.get(Video, video2.id)
v1.file_group.title = 'Same Title'
v2.file_group.title = 'Same Title'
v1.source_id = same_source_id
v2.source_id = same_source_id
# Give v1 a URL so it has SOME data, but still less than v2
v1.file_group.url = 'https://example.com/video1'
# Get preview - should detect conflicts with quality_rank
with get_db_session() as session:
preview = get_reorganization_preview(collection_id, session)
assert preview.has_conflicts is True, "Should detect conflicts"
assert len(preview.conflicts) == 1, "Should have 1 conflict"
conflict = preview.conflicts[0]
files = conflict['conflicting_files']
assert len(files) == 2, "Should have 2 conflicting files"
# Verify quality_rank is present on all video files
for file_info in files:
assert 'quality_rank' in file_info, f"quality_rank missing from {file_info}"
assert file_info['quality_rank'] is not None, f"quality_rank should not be None for videos"
# Verify files are sorted by quality_rank (highest first)
ranks = [f['quality_rank'] for f in files]
assert ranks == sorted(ranks, reverse=True), \
f"Files should be sorted by quality_rank descending. Got: {ranks}"
# The video with more metadata (video2) should rank higher
video2_info = next(f for f in files if f['file_group_id'] == video2.file_group_id)
video1_info = next(f for f in files if f['file_group_id'] == video1.file_group_id)
assert video2_info['quality_rank'] > video1_info['quality_rank'], \
f"Video with more metadata should rank higher. v2={video2_info['quality_rank']}, v1={video1_info['quality_rank']}"
# The highest ranked video should be first
assert files[0]['file_group_id'] == video2.file_group_id, "Highest ranked video should be first"
# ============================================================================
# Filename Parsing Fallback Tests
# ============================================================================
@pytest.mark.parametrize("filename,file_format,expected_result", [
# Filename with all fields: uploader_date_sourceid_title.ext
# Should work with complex formats since filename parsing provides all metadata
(
"Learning Self-Reliance_20170529_p_MzsCFkUPU_How to Build a Solar Generator.mp4",
"%(upload_year)s/%(uploader)s_%(upload_date)s_%(id)s_%(title)s.%(ext)s",
True, # Should work - filename has uploader, date, and source_id
),
# Filename without source_id: uploader_date_title.ext pattern
# Should NOT work with format requiring %(id)s
(
"Commsprepper_20130113_110Ah Car Battery connected to 80W Solar Panel.mp4",
"%(upload_year)s/%(uploader)s_%(upload_date)s_%(id)s_%(title)s.%(ext)s",
False, # Should fail - filename doesn't have source_id and format requires it
),
# Filename without source_id but format doesn't require it
(
"Commsprepper_20130113_110Ah Car Battery.mp4",
"%(upload_year)s/%(uploader)s_%(upload_date)s_%(title)s.%(ext)s",
False, # Should still fail - all-or-nothing: no source_id means no fallback
),
# Simple title-only format (always works)
(
"random_video_name.mp4",
"%(title)s.%(ext)s",
True, # Should work - format only requires title
),
])
def test_video_metadata_fallback_from_filename(
test_session, test_directory,
filename, file_format, expected_result
):
"""Test that _video_has_required_metadata uses filename parsing as all-or-nothing fallback.
Videos without info.json can use filename parsing to extract metadata,
but ONLY if the filename provides ALL three fields: channel, date, source_id.
This prevents cherry-picking individual fields which could lead to inconsistent data.
"""
from wrolpi.collections.reorganize import _video_has_required_metadata
# Create channel collection
channel_dir = test_directory / 'videos' / 'FallbackTestChannel'
channel_dir.mkdir(parents=True, exist_ok=True)
collection = Collection(
name='FallbackTestChannel',
kind='channel',
directory=channel_dir,
)
test_session.add(collection)
test_session.flush()
channel = Channel(name='FallbackTestChannel', collection_id=collection.id, directory=channel_dir)
test_session.add(channel)
test_session.commit()
# Create video file with specific filename
video_path = channel_dir / filename
video_path.touch()
# Create FileGroup
fg = FileGroup()
fg.directory = channel_dir
fg.primary_path = video_path
fg.files = [{'path': filename, 'mimetype': 'video/mp4'}]
fg.title = video_path.stem # Title from filename stem
# Intentionally NOT setting published_datetime to test fallback
test_session.add(fg)
test_session.flush()
# Create Video WITHOUT source_id or info.json - forces filename fallback
video = Video(file_group_id=fg.id, channel_id=channel.id)
# Intentionally NOT setting source_id to test fallback
test_session.add(video)
test_session.commit()
test_session.refresh(video)
result = _video_has_required_metadata(video, file_format)
assert result is expected_result, (
f"For filename '{filename}' with format '{file_format}', "
f"expected {expected_result} but got {result}"
)
def test_video_metadata_fallback_uses_channel_name_for_uploader(test_session, test_directory):
"""Test that video.channel.name is used as final fallback for uploader.
Even if filename parsing fails (no source_id in filename), we can still
use video.channel.name for the uploader field as a separate fallback.
"""
from wrolpi.collections.reorganize import _video_has_required_metadata
# Create channel collection
channel_dir = test_directory / 'videos' / 'ChannelNameFallback'
channel_dir.mkdir(parents=True, exist_ok=True)
collection = Collection(
name='ChannelNameFallback',
kind='channel',
directory=channel_dir,
)
test_session.add(collection)
test_session.flush()
channel = Channel(name='MyChannel', collection_id=collection.id, directory=channel_dir)
test_session.add(channel)
test_session.commit()
# Create video file with a title-only filename (no metadata in name)
filename = "random_video.mp4"
video_path = channel_dir / filename
video_path.touch()
# Create FileGroup with published_datetime
from datetime import datetime, timezone
fg = FileGroup()
fg.directory = channel_dir
fg.primary_path = video_path
fg.files = [{'path': filename, 'mimetype': 'video/mp4'}]
fg.title = 'Random Video'
fg.published_datetime = datetime(2024, 6, 15, tzinfo=timezone.utc)
test_session.add(fg)
test_session.flush()
# Create Video with source_id but NO uploader in info.json
video = Video(file_group_id=fg.id, channel_id=channel.id, source_id='abc123')
test_session.add(video)
test_session.commit()
test_session.refresh(video)
# Format requires uploader - should use channel.name as fallback
file_format = "%(uploader)s_%(upload_date)s_%(id)s_%(title)s.%(ext)s"
result = _video_has_required_metadata(video, file_format)
assert result is True, (
"Should succeed because video.channel.name provides uploader fallback"
)
@pytest.mark.parametrize(
"filename,channel_name,title,db_datetime,db_source_id,expected_year,expected_uploader,expected_source_id,should_not_contain",
[
# Complete filename with all fields - channel name takes priority over filename
# This ensures reorganized files use the WROLPi channel name, not the YouTube uploader
(
"TestUploader_20170529_p_MzsCFkUPU_My Test Video.mp4",
"FormatFallbackChannel",
"My Test Video",
None, # No DB datetime
None, # No DB source_id
"2017",
"FormatFallbackChannel", # Channel name takes priority
"p_MzsCFkUPU",
["TestUploader"], # Should NOT contain filename uploader
),
# Incomplete filename (no source_id) - uses DB values, not filename
(
"FilenameUploader_20200101_Just A Title.mp4",
"ChannelFromDB",
"Just A Title",
"2024-06-15", # DB datetime
"db_source_id", # DB source_id
"2024",
"ChannelFromDB",
"db_source_id",
["FilenameUploader", "2020"], # Should NOT contain these filename-parsed values
),
],
)
def test_format_video_filename_fallback_behavior(
test_session,
test_directory,
filename,
channel_name,
title,
db_datetime,
db_source_id,
expected_year,
expected_uploader,
expected_source_id,
should_not_contain,
):
"""Test format_video_filename fallback behavior with various scenarios.
Tests both:
1. Complete filename with all fields → uses filename fallback values
2. Incomplete filename (no source_id) with DB values → uses DB values, not filename
"""
from datetime import datetime, timezone
from modules.videos.lib import format_video_filename
# Create channel collection
channel_dir = test_directory / 'videos' / channel_name
channel_dir.mkdir(parents=True, exist_ok=True)
collection = Collection(
name=channel_name,
kind='channel',
directory=channel_dir,
)
test_session.add(collection)
test_session.flush()
channel = Channel(name=channel_name, collection_id=collection.id, directory=channel_dir)
test_session.add(channel)
test_session.commit()
# Create video file
video_path = channel_dir / filename
video_path.touch()
# Create FileGroup
fg = FileGroup()
fg.directory = channel_dir
fg.primary_path = video_path
fg.files = [{'path': filename, 'mimetype': 'video/mp4'}]
fg.title = title
if db_datetime:
year, month, day = map(int, db_datetime.split('-'))
fg.published_datetime = datetime(year, month, day, tzinfo=timezone.utc)
test_session.add(fg)
test_session.flush()
# Create Video
video = Video(file_group_id=fg.id, channel_id=channel.id, source_id=db_source_id)
test_session.add(video)
test_session.commit()
test_session.refresh(video)
# Format with year subdirectory
file_format = "%(upload_year)s/%(uploader)s_%(upload_date)s_%(id)s_%(title)s.%(ext)s"
result = format_video_filename(video, file_format)
# Verify expected values are present
assert f'{expected_year}/' in result, f"Expected year {expected_year}. Got: {result}"
assert f'{expected_uploader}_' in result, f"Expected uploader {expected_uploader}. Got: {result}"
assert expected_source_id in result, f"Expected source_id {expected_source_id}. Got: {result}"
# Verify forbidden values are NOT present (for all-or-nothing behavior)
for forbidden in should_not_contain:
assert forbidden not in result, f"Should NOT contain '{forbidden}'. Got: {result}"
def test_preview_excludes_files_already_in_correct_location(test_session, test_directory, video_factory):
"""Files already in the correct location should not appear in preview or be counted as needing move.
This tests that when a video's current path matches the computed new path, it is excluded from:
- files_needing_move count
- sample_moves list
"""
from modules.videos.lib import get_videos_downloader_config, format_video_filename
# Set up a simple format using the correct nested config structure
config = get_videos_downloader_config()
original_format = config._config['yt_dlp_options']['file_name_format']
# Use simple title-only format for predictability
yt_dlp_options = dict(config._config['yt_dlp_options'])
yt_dlp_options['file_name_format'] = '%(title)s.%(ext)s'
config._config['yt_dlp_options'] = yt_dlp_options
try:
# Create channel collection
channel_dir = test_directory / 'videos' / 'CorrectLocationChannel'
channel_dir.mkdir(parents=True, exist_ok=True)
with get_db_session(commit=True) as session:
collection = Collection(
name='CorrectLocationChannel',
kind='channel',
directory=channel_dir,
file_format='%(old_format)s.%(ext)s' # Different from config, so needs_reorganization=True
)
session.add(collection)
session.flush()
channel = Channel(name='CorrectLocationChannel', collection_id=collection.id, directory=channel_dir)
session.add(channel)
session.commit()
channel_id = channel.id
collection_id = collection.id
# Create a video with a simple title (no special characters)
# The video_factory will place it at channel_dir/SimpleTitle.mp4
video = video_factory(
channel_id=channel_id,
title='SimpleTitle',
with_video_file=True,
)
# Verify the video is at the expected location
with get_db_session() as session:
Upgrade to SQLAlchemy 2.0 (synchronous) (#803) SQLAlchemy 1.3 has been end-of-life since 2021, and alembic was held at 1.10 because newer releases need SQLAlchemy 2. This is a mechanical port; the code stays synchronous. - SQLAlchemy[asyncio]==2.0.54, alembic==1.20.0. - Model annotations use Mapped[...] (2.0 rejects bare annotations on mapped attributes); declarative_base moves to sqlalchemy.orm. - Plain SQL strings go through text() or exec_driver_sql; schema_ddl.execute_sql accepts a SQLAlchemy Connection or a raw sqlite3 connection, so rebuild_fts works from the baseline migration again. - Session.get replaces Query.get; session.in_transaction() replaces session.transaction; cache_ok on MediaPathType and FancyJSON. - The tag-directory sync query joins through TagFile's relationships instead of the legacy two-entity outerjoin. - Section-hint rows use attribute access (2.0 rows are tuples). - Migrations run on an AUTOCOMMIT connection, matching 1.3, where each statement committed on its own. 2.0 autobegins, so the unique-stem migration's read snapshot could not become a writer after its helpers wrote on another connection ("database is locked"). The begin listener emits no BEGIN for AUTOCOMMIT connections. - Newer alembic compares column types: WrolpiKV now describes the TEXT table its migration created. - The test database installs its DDL inside engine.begin(); 2.0 connections no longer autocommit, so the FTS tables were rolled back. - Dependabot now holds SQLAlchemy at 2.0 instead of 1.3. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:08:32 -06:00
video_obj = session.get(Video, video.id)
current_path = video_obj.file_group.primary_path
# The format_video_filename should produce the same filename
expected_filename = format_video_filename(video_obj, '%(title)s.%(ext)s')
expected_path = channel_dir / expected_filename
# Paths should match (this is the precondition for our test)
assert str(current_path) == str(expected_path), \
f"Test setup error: paths don't match. Current: {current_path}, Expected: {expected_path}"
# Now get the preview - the file should NOT need moving
with get_db_session() as session:
preview = get_reorganization_preview(collection_id, session)
# Since the file is already in the correct location, it should NOT be in the preview
assert preview.files_needing_move == 0, \
f"File already in correct location should not need moving. Got {preview.files_needing_move} files needing move"
assert len(preview.sample_moves) == 0, \
f"File already in correct location should not appear in sample_moves. Got: {preview.sample_moves}"
finally:
yt_dlp_options = dict(config._config['yt_dlp_options'])
yt_dlp_options['file_name_format'] = original_format
config._config['yt_dlp_options'] = yt_dlp_options