Remove duplicate folder scanning feature
Delete folder_rename_service.py. Stub out get_duplicate_folders API to return empty response. Update folder_scan_service and tests to skip rename step. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,6 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
import warnings
|
import warnings
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, List, Optional
|
from typing import Any, List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
@@ -20,9 +19,7 @@ from src.server.exceptions import (
|
|||||||
from src.server.models.anime import AnimeMetadataUpdate
|
from src.server.models.anime import AnimeMetadataUpdate
|
||||||
from src.server.services.anime_service import AnimeService, AnimeServiceError
|
from src.server.services.anime_service import AnimeService, AnimeServiceError
|
||||||
from src.server.services.background_loader_service import BackgroundLoaderService
|
from src.server.services.background_loader_service import BackgroundLoaderService
|
||||||
from src.server.services.scheduler.folder_rename_service import (
|
|
||||||
_scan_for_pre_existing_duplicates,
|
|
||||||
)
|
|
||||||
from src.server.utils.dependencies import (
|
from src.server.utils.dependencies import (
|
||||||
get_anime_service,
|
get_anime_service,
|
||||||
get_background_loader_service,
|
get_background_loader_service,
|
||||||
@@ -79,26 +76,14 @@ async def get_anime_status(
|
|||||||
|
|
||||||
|
|
||||||
class DuplicateFolderGroup(BaseModel):
|
class DuplicateFolderGroup(BaseModel):
|
||||||
"""A group of duplicate folders for the same series.
|
"""Placeholder - duplicates functionality removed."""
|
||||||
|
|
||||||
Attributes:
|
|
||||||
key: Series key (provider-assigned unique identifier)
|
|
||||||
folders: List of folder names that are duplicates
|
|
||||||
folder_count: Number of duplicate folders
|
|
||||||
"""
|
|
||||||
key: str = Field(..., description="Series key (unique identifier)")
|
key: str = Field(..., description="Series key (unique identifier)")
|
||||||
folders: List[str] = Field(..., description="List of duplicate folder names")
|
folders: List[str] = Field(..., description="List of duplicate folder names")
|
||||||
folder_count: int = Field(..., description="Number of duplicate folders")
|
folder_count: int = Field(..., description="Number of duplicate folders")
|
||||||
|
|
||||||
|
|
||||||
class DuplicateFoldersResponse(BaseModel):
|
class DuplicateFoldersResponse(BaseModel):
|
||||||
"""Response model for duplicate folders listing.
|
"""Placeholder - duplicates functionality removed."""
|
||||||
|
|
||||||
Attributes:
|
|
||||||
total_groups: Total number of duplicate groups found
|
|
||||||
duplicate_groups: List of duplicate folder groups
|
|
||||||
message: Human-readable summary
|
|
||||||
"""
|
|
||||||
total_groups: int = Field(..., description="Total number of duplicate groups")
|
total_groups: int = Field(..., description="Total number of duplicate groups")
|
||||||
duplicate_groups: List[DuplicateFolderGroup] = Field(
|
duplicate_groups: List[DuplicateFolderGroup] = Field(
|
||||||
..., description="List of duplicate folder groups"
|
..., description="List of duplicate folder groups"
|
||||||
@@ -112,64 +97,13 @@ async def get_duplicate_folders(
|
|||||||
) -> DuplicateFoldersResponse:
|
) -> DuplicateFoldersResponse:
|
||||||
"""List all pre-existing duplicate folder groups.
|
"""List all pre-existing duplicate folder groups.
|
||||||
|
|
||||||
Scans the anime directory for folders with tvshow.nfo files that
|
Note: Duplicate folder scanning has been removed. Returns empty response.
|
||||||
map to the same series key. Returns groups of duplicates for
|
|
||||||
manual review and cleanup.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
DuplicateFoldersResponse with groups of duplicate folders
|
|
||||||
|
|
||||||
Note:
|
|
||||||
Not all duplicate folders are safe to merge - some may belong
|
|
||||||
to different releases (e.g., dubbed vs. subbed). Review carefully
|
|
||||||
before taking action.
|
|
||||||
"""
|
"""
|
||||||
try:
|
return DuplicateFoldersResponse(
|
||||||
if not settings.anime_directory:
|
total_groups=0,
|
||||||
return DuplicateFoldersResponse(
|
duplicate_groups=[],
|
||||||
total_groups=0,
|
message="Duplicate folder scanning has been removed.",
|
||||||
duplicate_groups=[],
|
)
|
||||||
message="Anime directory not configured",
|
|
||||||
)
|
|
||||||
|
|
||||||
anime_dir = Path(settings.anime_directory)
|
|
||||||
if not anime_dir.is_dir():
|
|
||||||
return DuplicateFoldersResponse(
|
|
||||||
total_groups=0,
|
|
||||||
duplicate_groups=[],
|
|
||||||
message=f"Anime directory not found: {anime_dir}",
|
|
||||||
)
|
|
||||||
|
|
||||||
duplicates = _scan_for_pre_existing_duplicates(anime_dir)
|
|
||||||
|
|
||||||
groups = [
|
|
||||||
DuplicateFolderGroup(
|
|
||||||
key=dup.key,
|
|
||||||
folders=dup.folders,
|
|
||||||
folder_count=dup.count,
|
|
||||||
)
|
|
||||||
for dup in duplicates
|
|
||||||
]
|
|
||||||
|
|
||||||
if groups:
|
|
||||||
message = (
|
|
||||||
f"Found {len(groups)} duplicate group(s). "
|
|
||||||
"Review carefully - some duplicates may be different releases "
|
|
||||||
"(e.g., dubbed vs. subbed)."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
message = "No duplicate folders found."
|
|
||||||
|
|
||||||
return DuplicateFoldersResponse(
|
|
||||||
total_groups=len(groups),
|
|
||||||
duplicate_groups=groups,
|
|
||||||
message=message,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error("Failed to scan for duplicate folders: %s", str(exc))
|
|
||||||
raise ServerError(
|
|
||||||
message=f"Failed to scan for duplicates: {str(exc)}"
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
|
|
||||||
class AnimeSummary(BaseModel):
|
class AnimeSummary(BaseModel):
|
||||||
|
|||||||
@@ -19,6 +19,4 @@ __all__ = [
|
|||||||
"SchedulerServiceError",
|
"SchedulerServiceError",
|
||||||
"get_scheduler_service",
|
"get_scheduler_service",
|
||||||
"reset_scheduler_service",
|
"reset_scheduler_service",
|
||||||
# Sub-services (still in scheduler folder)
|
|
||||||
"folder_rename_service",
|
|
||||||
]
|
]
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
"""Stub module for folder_rename_service (removed)."""
|
|
||||||
|
|
||||||
from typing import Any, Dict, List
|
|
||||||
|
|
||||||
|
|
||||||
def _scan_for_pre_existing_duplicates(anime_dir: str) -> List[Any]:
|
|
||||||
"""Stub: returns empty list as folder_rename_service was removed.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
anime_dir: Unused.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Empty list.
|
|
||||||
"""
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def validate_and_rename_series_folders(
|
|
||||||
anime_dir: str,
|
|
||||||
dry_run: bool = False,
|
|
||||||
background_loader: Any = None
|
|
||||||
) -> Dict[str, int]:
|
|
||||||
"""Stub: returns empty stats as folder_rename_service was removed.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
anime_dir: Unused.
|
|
||||||
dry_run: Unused.
|
|
||||||
background_loader: Unused.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Empty stats dict.
|
|
||||||
"""
|
|
||||||
return {"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0}
|
|
||||||
@@ -85,19 +85,8 @@ class FolderScanService:
|
|||||||
logger.info("NFO repair scan skipped — NFO service removed")
|
logger.info("NFO repair scan skipped — NFO service removed")
|
||||||
|
|
||||||
# 1.4 — Validate and rename series folders after NFO repair.
|
# 1.4 — Validate and rename series folders after NFO repair.
|
||||||
# Note: folder_rename_service removed - now a stub that does nothing
|
# Note: folder_rename_service removed - skip entirely
|
||||||
logger.info("Starting folder rename validation")
|
logger.info("Folder rename validation skipped — service removed")
|
||||||
from src.server.services.scheduler.folder_rename_service import (
|
|
||||||
validate_and_rename_series_folders,
|
|
||||||
)
|
|
||||||
rename_stats = await validate_and_rename_series_folders()
|
|
||||||
logger.info(
|
|
||||||
"Folder rename validation complete",
|
|
||||||
scanned=rename_stats["scanned"],
|
|
||||||
renamed=rename_stats["renamed"],
|
|
||||||
skipped=rename_stats["skipped"],
|
|
||||||
errors=rename_stats["errors"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# 1.5 — Check and download missing poster.jpg files.
|
# 1.5 — Check and download missing poster.jpg files.
|
||||||
logger.info("Starting poster check")
|
logger.info("Starting poster check")
|
||||||
|
|||||||
@@ -107,13 +107,6 @@ class TestRunFolderScanPrerequisites:
|
|||||||
"""Scan logs start and completion when prerequisites are met."""
|
"""Scan logs start and completion when prerequisites are met."""
|
||||||
with patch.object(
|
with patch.object(
|
||||||
folder_scan_service, "_prerequisites_met", return_value=True
|
folder_scan_service, "_prerequisites_met", return_value=True
|
||||||
), patch(
|
|
||||||
"src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
), patch(
|
|
||||||
"src.server.services.scheduler.folder_rename_service.validate_and_rename_series_folders",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
|
|
||||||
), patch.object(
|
), patch.object(
|
||||||
folder_scan_service,
|
folder_scan_service,
|
||||||
"check_and_download_missing_posters",
|
"check_and_download_missing_posters",
|
||||||
@@ -147,10 +140,6 @@ class TestNfoRepairIntegration:
|
|||||||
"""NFO repair scan is skipped since NFO service removed."""
|
"""NFO repair scan is skipped since NFO service removed."""
|
||||||
with patch.object(
|
with patch.object(
|
||||||
folder_scan_service, "_prerequisites_met", return_value=True
|
folder_scan_service, "_prerequisites_met", return_value=True
|
||||||
), patch(
|
|
||||||
"src.server.services.scheduler.folder_rename_service.validate_and_rename_series_folders",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
|
|
||||||
), patch.object(
|
), patch.object(
|
||||||
folder_scan_service,
|
folder_scan_service,
|
||||||
"check_and_download_missing_posters",
|
"check_and_download_missing_posters",
|
||||||
@@ -158,62 +147,30 @@ class TestNfoRepairIntegration:
|
|||||||
return_value={"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0},
|
return_value={"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0},
|
||||||
):
|
):
|
||||||
await folder_scan_service.run_folder_scan()
|
await folder_scan_service.run_folder_scan()
|
||||||
# NFO repair is skipped - verify scan continues to folder rename
|
|
||||||
# No exception means the stub worked correctly
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 1.4 – Folder rename integration
|
# 1.4 – Folder rename (removed)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestFolderRenameIntegration:
|
class TestFolderRenameRemoved:
|
||||||
"""Test validate_and_rename_series_folders is called and stats logged."""
|
"""Folder rename validation was removed; scan continues to poster check."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_calls_folder_rename_service(self, folder_scan_service, tmp_path):
|
async def test_folder_rename_skipped_poster_check_runs(
|
||||||
"""run_folder_scan must call validate_and_rename_series_folders."""
|
|
||||||
with patch.object(
|
|
||||||
folder_scan_service, "_prerequisites_met", return_value=True
|
|
||||||
), patch(
|
|
||||||
"src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
), patch(
|
|
||||||
"src.server.services.scheduler.folder_rename_service.validate_and_rename_series_folders",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value={"scanned": 5, "renamed": 2, "skipped": 2, "errors": 1},
|
|
||||||
) as mock_rename, patch.object(
|
|
||||||
folder_scan_service,
|
|
||||||
"check_and_download_missing_posters",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value={"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0},
|
|
||||||
):
|
|
||||||
await folder_scan_service.run_folder_scan()
|
|
||||||
mock_rename.assert_awaited_once()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_folder_rename_failure_does_not_crash_scan(
|
|
||||||
self, folder_scan_service, tmp_path
|
self, folder_scan_service, tmp_path
|
||||||
):
|
):
|
||||||
"""If validate_and_rename_series_folders raises, the broad except
|
"""Folder rename is skipped; scan continues to poster check."""
|
||||||
catches it and the scan stops — poster check is NOT invoked."""
|
|
||||||
with patch.object(
|
with patch.object(
|
||||||
folder_scan_service, "_prerequisites_met", return_value=True
|
folder_scan_service, "_prerequisites_met", return_value=True
|
||||||
), patch(
|
|
||||||
"src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
), patch(
|
|
||||||
"src.server.services.scheduler.folder_rename_service.validate_and_rename_series_folders",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
side_effect=RuntimeError("rename failed"),
|
|
||||||
), patch.object(
|
), patch.object(
|
||||||
folder_scan_service,
|
folder_scan_service,
|
||||||
"check_and_download_missing_posters",
|
"check_and_download_missing_posters",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value={"scanned": 0, "downloaded": 0, "skipped": 0, "errors": 0},
|
return_value={"scanned": 5, "downloaded": 2, "skipped": 2, "errors": 1},
|
||||||
) as mock_poster:
|
) as mock_poster:
|
||||||
await folder_scan_service.run_folder_scan()
|
await folder_scan_service.run_folder_scan()
|
||||||
# Broad except stops the scan; poster check is skipped
|
mock_poster.assert_awaited_once()
|
||||||
mock_poster.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -536,23 +493,16 @@ class TestRunFolderScanFull:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_full_scan_happy_path(self, folder_scan_service, tmp_path):
|
async def test_full_scan_happy_path(self, folder_scan_service, tmp_path):
|
||||||
"""All sub-tasks succeed. NFO repair is now a stub."""
|
"""All sub-tasks succeed. NFO repair and folder rename are stubs."""
|
||||||
with patch.object(
|
with patch.object(
|
||||||
folder_scan_service, "_prerequisites_met", return_value=True
|
folder_scan_service, "_prerequisites_met", return_value=True
|
||||||
), patch(
|
), patch.object(
|
||||||
"src.server.services.scheduler.folder_rename_service.validate_and_rename_series_folders",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value={"scanned": 3, "renamed": 1, "skipped": 1, "errors": 1},
|
|
||||||
) as mock_rename, patch.object(
|
|
||||||
folder_scan_service,
|
folder_scan_service,
|
||||||
"check_and_download_missing_posters",
|
"check_and_download_missing_posters",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value={"scanned": 3, "downloaded": 2, "skipped": 1, "errors": 0},
|
return_value={"scanned": 3, "downloaded": 2, "skipped": 1, "errors": 0},
|
||||||
) as mock_poster:
|
) as mock_poster:
|
||||||
await folder_scan_service.run_folder_scan()
|
await folder_scan_service.run_folder_scan()
|
||||||
|
|
||||||
# NFO repair is now a stub - not awaited in code
|
|
||||||
mock_rename.assert_awaited_once()
|
|
||||||
mock_poster.assert_awaited_once()
|
mock_poster.assert_awaited_once()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -560,10 +510,6 @@ class TestRunFolderScanFull:
|
|||||||
"""Empty library → all stats zero."""
|
"""Empty library → all stats zero."""
|
||||||
with patch.object(
|
with patch.object(
|
||||||
folder_scan_service, "_prerequisites_met", return_value=True
|
folder_scan_service, "_prerequisites_met", return_value=True
|
||||||
), patch(
|
|
||||||
"src.server.services.scheduler.folder_rename_service.validate_and_rename_series_folders",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value={"scanned": 0, "renamed": 0, "skipped": 0, "errors": 0},
|
|
||||||
), patch.object(
|
), patch.object(
|
||||||
folder_scan_service,
|
folder_scan_service,
|
||||||
"check_and_download_missing_posters",
|
"check_and_download_missing_posters",
|
||||||
|
|||||||
Reference in New Issue
Block a user