fix(folder_scan): await NFO repair before folder rename

folder_rename_service depends on clean NFO files but repair tasks
were fire-and-forget. Now collect all repair tasks and await them
with asyncio.gather before validate_and_rename_series_folders runs.

Also update tests that mock asyncio.create_task to also mock
asyncio.gather since perform_nfo_repair_scan now awaits tasks.
This commit is contained in:
2026-06-01 21:37:28 +02:00
parent c58b42dfa5
commit a54c285994
2 changed files with 29 additions and 10 deletions

View File

@@ -129,6 +129,7 @@ async def perform_nfo_repair_scan(background_loader=None) -> None:
queued = 0 queued = 0
total = 0 total = 0
missing_nfo_count = 0 missing_nfo_count = 0
repair_tasks: list[asyncio.Task] = []
for series_dir in sorted(anime_dir.iterdir()): for series_dir in sorted(anime_dir.iterdir()):
if not series_dir.is_dir(): if not series_dir.is_dir():
continue continue
@@ -137,19 +138,31 @@ async def perform_nfo_repair_scan(background_loader=None) -> None:
if not nfo_path.exists(): if not nfo_path.exists():
# Create minimal NFO for series without one # Create minimal NFO for series without one
missing_nfo_count += 1 missing_nfo_count += 1
asyncio.create_task( repair_tasks.append(
_create_missing_nfo(series_dir, series_name), asyncio.create_task(
name=f"nfo_create:{series_name}", _create_missing_nfo(series_dir, series_name),
name=f"nfo_create:{series_name}",
)
) )
continue continue
total += 1 total += 1
if nfo_needs_repair(nfo_path): if nfo_needs_repair(nfo_path):
queued += 1 queued += 1
asyncio.create_task( repair_tasks.append(
_repair_one_series(series_dir, series_name), asyncio.create_task(
name=f"nfo_repair:{series_name}", _repair_one_series(series_dir, series_name),
name=f"nfo_repair:{series_name}",
)
) )
if repair_tasks:
logger.info(
"NFO repair scan: waiting for %d repair/create tasks to complete",
len(repair_tasks),
)
await asyncio.gather(*repair_tasks, return_exceptions=True)
logger.info("NFO repair scan tasks completed")
logger.info( logger.info(
"NFO repair scan complete: %d of %d series queued for repair, %d missing NFOs queued for creation", "NFO repair scan complete: %d of %d series queued for repair, %d missing NFOs queued for creation",
queued, queued,
@@ -182,10 +195,10 @@ class FolderScanService:
if not self._prerequisites_met(): if not self._prerequisites_met():
return return
# 1.3 — Repair incomplete NFO files in the background. # 1.3 — Repair incomplete NFO files (synchronous, waits for completion).
logger.info("Starting NFO repair scan as part of folder scan") logger.info("Starting NFO repair scan as part of folder scan")
await perform_nfo_repair_scan(background_loader=None) await perform_nfo_repair_scan(background_loader=None)
logger.info("NFO repair scan queued; repairs will continue in background") logger.info("NFO repair scan complete")
# 1.4 — Validate and rename series folders after NFO repair. # 1.4 — Validate and rename series folders after NFO repair.
logger.info("Starting folder rename validation") logger.info("Starting folder rename validation")

View File

@@ -816,11 +816,14 @@ class TestPerformNfoRepairScan:
return_value=mock_repair_service, return_value=mock_repair_service,
), patch( ), patch(
"asyncio.create_task" "asyncio.create_task"
) as mock_create_task: ) as mock_create_task, patch(
"asyncio.gather", new_callable=AsyncMock
) as mock_gather:
mock_factory_cls.return_value.create.return_value = MagicMock() mock_factory_cls.return_value.create.return_value = MagicMock()
await perform_nfo_repair_scan(background_loader=AsyncMock()) await perform_nfo_repair_scan(background_loader=AsyncMock())
mock_create_task.assert_called_once() mock_create_task.assert_called_once()
mock_gather.assert_called_once()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_skips_complete_series(self, tmp_path): async def test_skips_complete_series(self, tmp_path):
@@ -876,8 +879,11 @@ class TestPerformNfoRepairScan:
return_value=mock_repair_service, return_value=mock_repair_service,
), patch( ), patch(
"asyncio.create_task" "asyncio.create_task"
) as mock_create_task: ) as mock_create_task, patch(
"asyncio.gather", new_callable=AsyncMock
) as mock_gather:
mock_factory_cls.return_value.create.return_value = MagicMock() mock_factory_cls.return_value.create.return_value = MagicMock()
await perform_nfo_repair_scan(background_loader=None) await perform_nfo_repair_scan(background_loader=None)
mock_create_task.assert_called_once() mock_create_task.assert_called_once()
mock_gather.assert_called_once()