diff --git a/src/server/services/scheduler/folder_rename_service.py b/src/server/services/scheduler/folder_rename_service.py
deleted file mode 100644
index a3a8be2..0000000
--- a/src/server/services/scheduler/folder_rename_service.py
+++ /dev/null
@@ -1,739 +0,0 @@
-"""Folder rename service for validating and renaming series folders.
-
-After NFO repair, this service iterates over every subfolder in
-``settings.anime_directory`` that contains a ``tvshow.nfo``. For each
-folder it parses the NFO to extract ``
`` and ````, computes
-the expected folder name ``f"{title} ({year})"``, sanitises it for
-filesystem safety, and renames the folder if the current name differs.
-
-Database records (``AnimeSeries.folder``, ``Episode.file_path``,
-``DownloadQueueItem.file_destination``) are updated atomically to
-reflect the new paths.
-"""
-from __future__ import annotations
-
-import logging
-import re
-import shutil
-from collections import defaultdict
-from dataclasses import dataclass
-from pathlib import Path
-from typing import Optional
-
-from lxml import etree
-
-from src.config.settings import settings
-from src.server.database.connection import get_db_session
-from src.server.database.service import (
- AnimeSeriesService,
- DownloadQueueService,
- EpisodeService,
-)
-from src.server.utils.dependencies import get_download_service
-from src.server.utils.filesystem import sanitize_folder_name
-
-logger = logging.getLogger(__name__)
-
-# Pre-compiled pattern for stripping existing year suffixes
-_YEAR_SUFFIX_PATTERN = re.compile(r'(\s*\(\d{4}\))+\s*$')
-
-
-@dataclass
-class DuplicateGroup:
- """Represents a group of duplicate folders for the same series.
-
- Attributes:
- key: The series key (folder name before rename).
- folders: List of folder paths that map to this series.
- nfo_paths: List of corresponding NFO file paths.
- """
-
- key: str
- folders: list[str]
- nfo_paths: list[Path]
-
- @property
- def count(self) -> int:
- return len(self.folders)
-
- def __repr__(self) -> str:
- return f"DuplicateGroup(key={self.key!r}, folders={self.folders})"
-
-
-@dataclass
-class RenameStats:
- """Statistics from a folder rename operation."""
-
- scanned: int = 0
- renamed: int = 0
- skipped: int = 0
- errors: int = 0
-
- def to_dict(self) -> dict[str, int]:
- return {"scanned": self.scanned, "renamed": self.renamed, "skipped": self.skipped, "errors": self.errors}
-
-
-def _scan_for_pre_existing_duplicates(anime_dir: Path) -> list[DuplicateGroup]:
- """Scan anime directory for pre-existing duplicate folders.
-
- Groups folders by the series key extracted from their NFO files.
- Folders with the same title+year (same expected name) are flagged as duplicates.
-
- Args:
- anime_dir: Path to the anime directory to scan.
-
- Returns:
- List of DuplicateGroup objects, one per series with duplicate folders.
- """
- groups: dict[str, list[tuple[str, Path]]] = defaultdict(list)
-
- for series_dir in anime_dir.iterdir():
- if not series_dir.is_dir():
- continue
- nfo_path = series_dir / "tvshow.nfo"
- if not nfo_path.exists():
- continue
- title, year = _parse_nfo_title_and_year(nfo_path)
- if not title or not year:
- continue
- expected_name = _compute_expected_folder_name(title, year)
- groups[expected_name].append((series_dir.name, nfo_path))
-
- duplicates = []
- for key, items in groups.items():
- if len(items) > 1:
- folders = [item[0] for item in items]
- nfo_paths = [item[1] for item in items]
- duplicates.append(DuplicateGroup(key=key, folders=folders, nfo_paths=nfo_paths))
-
- return duplicates
-
-
-def _try_merge_duplicate_group(group: DuplicateGroup, dry_run: bool = False) -> bool:
- """Attempt to merge a duplicate group automatically.
-
- Uses the first folder as the canonical one and removes others if they are
- empty or contain only symlinks.
-
- Args:
- group: The DuplicateGroup to merge.
- dry_run: If True, only log actions without executing them.
-
- Returns:
- True if merge was successful, False otherwise.
- """
- if len(group.folders) < 2:
- return True
-
- canonical = group.folders[0]
- to_remove = group.folders[1:]
-
- for folder in to_remove:
- folder_path = group.nfo_paths[0].parent.parent / folder
- if not folder_path.exists():
- continue
-
- try:
- contents = list(folder_path.iterdir())
- except PermissionError:
- logger.warning("Permission denied accessing %s, skip merge", folder_path)
- return False
- except OSError:
- return False
-
- if not contents:
- if dry_run:
- logger.info("[DRY-RUN] Would delete empty duplicate folder: %s", folder_path)
- else:
- try:
- folder_path.rmdir()
- logger.info("Deleted empty duplicate folder: %s", folder_path)
- except OSError:
- return False
- continue
-
- canonical_path = folder_path.parent / canonical
- all_symlinks = all(
- item.is_symlink() and item.resolve() == canonical_path.resolve()
- for item in contents
- )
- if all_symlinks:
- if dry_run:
- logger.info("[DRY-RUN] Would remove symlinks in duplicate folder: %s", folder_path)
- else:
- for item in contents:
- item.unlink()
- try:
- folder_path.rmdir()
- logger.info("Removed symlink-only duplicate folder: %s", folder_path)
- except OSError:
- return False
- continue
-
- logger.warning(
- "Cannot auto-merge duplicate folders for '%s': %s (manual merge required)",
- group.key,
- [canonical] + to_remove,
- )
- return False
-
- return True
-
-
-def _parse_nfo_title_and_year(nfo_path: Path) -> tuple[Optional[str], Optional[str]]:
- """Parse a tvshow.nfo and return (title, year) text values.
-
- Args:
- nfo_path: Absolute path to the ``tvshow.nfo`` file.
-
- Returns:
- Tuple of (title, year) where either may be ``None`` if missing
- or empty.
- """
- try:
- tree = etree.parse(str(nfo_path))
- root = tree.getroot()
-
- title_elem = root.find("./title")
- year_elem = root.find("./year")
-
- title = title_elem.text.strip() if title_elem is not None and title_elem.text and title_elem.text.strip() else None
- year = year_elem.text.strip() if year_elem is not None and year_elem.text and year_elem.text.strip() else None
-
- return title, year
- except etree.XMLSyntaxError as exc:
- logger.warning("Malformed XML in %s: %s", nfo_path, exc)
- return None, None
- except Exception as exc:
- logger.warning("Unexpected error parsing %s: %s", nfo_path, exc)
- return None, None
-
-
-def _compute_expected_folder_name(title: str, year: str) -> str:
- """Compute the expected folder name from title and year.
-
- Removes any existing year suffixes (e.g., "(2021)") before adding the
- canonical one to prevent duplication across multiple folder rename runs.
-
- Args:
- title: Series title from NFO.
- year: Release year from NFO.
-
- Returns:
- Sanitised folder name in the format ``"{title} ({year})"``.
- """
- clean_title = _YEAR_SUFFIX_PATTERN.sub('', title).strip()
- year_suffix = f" ({year})"
- raw_name = f"{clean_title}{year_suffix}"
- return sanitize_folder_name(raw_name)
-
-
-def _is_series_being_downloaded(series_folder: str) -> bool:
- """Check whether the given series has an active or pending download.
-
- Args:
- series_folder: The series folder name (as stored in the DB).
-
- Returns:
- ``True`` if the series appears in the active download or the
- pending queue.
- """
- try:
- download_service = get_download_service()
- active = download_service._active_download
- if active and active.serie_folder == series_folder:
- return True
- for item in download_service._pending_queue:
- if item.serie_folder == series_folder:
- return True
- return False
- except Exception as exc:
- logger.warning(
- "Could not check download status for %s: %s", series_folder, exc
- )
- return True
-
-
-def _remove_key_file(path: Path) -> None:
- """Remove legacy 'key' file from a series folder.
-
- Args:
- path: Path to the series folder.
- """
- key_file = path / "key"
- if key_file.exists():
- try:
- key_file.unlink()
- logger.info("Removed legacy 'key' file after rename: %s", key_file)
- except OSError as exc:
- logger.warning("Could not remove legacy 'key' file %s: %s", key_file, exc)
-
-
-def _move_file(item: Path, dest: Path) -> bool:
- """Move a single file or directory to destination.
-
- Args:
- item: Source path to move.
- dest: Destination path.
-
- Returns:
- True if move succeeded, False otherwise.
- """
- try:
- item.rename(dest)
- logger.debug("Moved %s → %s", item, dest)
- return True
- except PermissionError as exc:
- logger.warning("Permission denied moving %s: %s", item, exc)
- return False
- except OSError as exc:
- logger.warning("OS error moving %s: %s", item, exc)
- return False
-
-
-def _cleanup_orphaned_folder(old_path: Path, new_path: Path, dry_run: bool = False) -> bool:
- """Clean up orphaned folder after successful rename.
-
- After a folder is successfully renamed to new_path, this function checks
- if the old_path still exists (orphaned folder) and removes it. If the
- old folder contains files, they are moved to new_path before deletion.
-
- Args:
- old_path: The original folder path before rename.
- new_path: The new folder path after rename.
- dry_run: If True, only log actions without executing them.
-
- Returns:
- True if old folder was cleaned up (or would be in dry-run mode),
- False if old folder does not exist or cleanup failed.
- """
- if not old_path.exists():
- logger.debug("Old folder does not exist, no cleanup needed: %s", old_path)
- return False
-
- try:
- contents = list(old_path.iterdir())
- except PermissionError as exc:
- logger.warning("Permission denied accessing old folder %s: %s", old_path, exc)
- return False
- except OSError as exc:
- logger.warning("OS error accessing old folder %s: %s", old_path, exc)
- return False
-
- if not contents:
- if dry_run:
- logger.info("[DRY-RUN] Would delete empty orphaned folder: %s", old_path)
- return True
- try:
- old_path.rmdir()
- logger.info("Deleted empty orphaned folder: %s", old_path)
- return True
- except PermissionError as exc:
- logger.warning("Permission denied deleting folder %s: %s", old_path, exc)
- return False
- except OSError as exc:
- logger.warning("OS error deleting folder %s: %s", old_path, exc)
- return False
-
- if dry_run:
- logger.info("[DRY-RUN] Would move %d files from orphaned folder %s to %s",
- len(contents), old_path, new_path)
- for item in contents:
- logger.info("[DRY-RUN] Would move: %s → %s", item, new_path / item.name)
- logger.info("[DRY-RUN] Would then delete orphaned folder: %s", old_path)
- return True
-
- files_moved = 0
- errors = 0
- for item in contents:
- if not _move_file(item, new_path / item.name):
- errors += 1
- else:
- files_moved += 1
-
- if files_moved > 0:
- logger.info("Moved %d files from orphaned folder to %s", files_moved, new_path)
-
- try:
- old_path.rmdir()
- logger.info("Deleted orphaned folder after moving contents: %s", old_path)
- return errors == 0
- except OSError as exc:
- logger.warning("Could not delete orphaned folder %s (may not be empty): %s", old_path, exc)
- return False
-
-
-async def _update_series_folder(db, series, new_folder: str) -> None:
- """Update AnimeSeries.folder in the database.
-
- Args:
- db: Database session.
- series: The AnimeSeries instance to update.
- new_folder: New folder name.
- """
- if series is None:
- return
-
- await AnimeSeriesService.update(db, series.id, folder=new_folder)
- logger.info("Updated AnimeSeries.folder: %s (id=%s)", new_folder, series.id)
-
-
-def _update_episode_paths(episodes, old_series_path: Path, new_series_path: Path) -> None:
- """Update Episode.file_path for all episodes of a series.
-
- Args:
- episodes: List of Episode instances.
- old_series_path: Path to the old series folder.
- new_series_path: Path to the new series folder.
- """
- for episode in episodes:
- if not episode.file_path:
- continue
- old_file_path = Path(episode.file_path)
- try:
- old_file_path.relative_to(old_series_path)
- new_file_path = new_series_path / old_file_path.relative_to(old_series_path)
- episode.file_path = str(new_file_path)
- logger.debug("Updated Episode.file_path: %s → %s", old_file_path, new_file_path)
- except ValueError:
- pass
-
-
-def _update_queue_destinations(
- queue_items,
- series_id,
- old_series_path: Path,
- new_series_path: Path,
-) -> None:
- """Update DownloadQueueItem.file_destination for pending items.
-
- Args:
- queue_items: List of DownloadQueueItem instances.
- series_id: ID of the series to filter by.
- old_series_path: Path to the old series folder.
- new_series_path: Path to the new series folder.
- """
- for item in queue_items:
- if item.series_id != series_id or not item.file_destination:
- continue
- old_dest = Path(item.file_destination)
- try:
- old_dest.relative_to(old_series_path)
- new_dest = new_series_path / old_dest.relative_to(old_series_path)
- item.file_destination = str(new_dest)
- logger.debug("Updated DownloadQueueItem.file_destination: %s → %s", old_dest, new_dest)
- except ValueError:
- pass
-
-
-async def _update_database_paths(
- old_folder: str,
- new_folder: str,
- anime_dir: Path,
-) -> None:
- """Update all database records that reference the old folder path.
-
- Updates:
- - ``AnimeSeries.folder`` → ``new_folder``
- - ``Episode.file_path`` → adjusted to new folder
- - ``DownloadQueueItem.file_destination`` → adjusted to new folder
-
- Args:
- old_folder: Previous folder name.
- new_folder: New folder name.
- anime_dir: Root anime directory path.
- """
- old_series_path = anime_dir / old_folder
- new_series_path = anime_dir / new_folder
-
- async with get_db_session() as db:
- series = await AnimeSeriesService.get_by_folder(db, old_folder)
- if series is None:
- all_series = await AnimeSeriesService.get_all(db)
- for s in all_series:
- if s.folder == old_folder:
- series = s
- break
-
- await _update_series_folder(db, series, new_folder)
-
- if series is None:
- return
-
- episodes = await EpisodeService.get_by_series(db, series.id)
- _update_episode_paths(episodes, old_series_path, new_series_path)
-
- await db.flush()
-
- queue_items = await DownloadQueueService.get_all(db, with_series=True)
- _update_queue_destinations(queue_items, series.id, old_series_path, new_series_path)
-
- await db.flush()
- logger.info("Database paths updated for series '%s' → '%s'", old_folder, new_folder)
-
-
-def _remove_duplicate_target_folder(
- series_dir: Path,
- current_name: str,
- expected_name: str,
- expected_path: Path,
-) -> bool:
- """Handle the case where the target folder already exists.
-
- Removes the source folder and its DB record to avoid orphaning
- episodes/downloads.
-
- Args:
- series_dir: Path to the series directory being processed.
- current_name: Current folder name.
- expected_name: Expected folder name.
- expected_path: Path to the expected (target) folder.
-
- Returns:
- True if folder was removed successfully, False otherwise.
- """
- logger.warning(
- "Cannot rename '%s' → '%s' — target already exists",
- current_name,
- expected_name,
- )
- try:
- try:
- contents = list(series_dir.iterdir())
- logger.warning(
- "REMOVING folder '%s' with %d items — target '%s' already exists",
- current_name,
- len(contents),
- expected_name,
- )
- for item in contents:
- logger.warning(" Would remove: %s", item)
- except OSError as exc:
- logger.warning(
- "Could not list contents of folder '%s' before removal: %s",
- current_name,
- exc,
- )
-
- shutil.rmtree(series_dir)
- logger.info(
- "Removed source folder '%s' — series already exists at target",
- current_name,
- )
-
- # Delete source DB record using synchronous helper
- _delete_series_db_record(current_name, expected_name)
-
- return True
- except OSError as exc:
- logger.error("Failed to remove source folder '%s': %s", current_name, exc)
- return False
-
-
-def _delete_series_db_record(current_name: str, expected_name: str) -> None:
- """Delete the series DB record for a folder that was removed.
-
- Args:
- current_name: The folder name to look up in the DB.
- expected_name: The target folder name (for logging).
- """
- try:
- import asyncio
- asyncio.run(_delete_series_db_record_async(current_name, expected_name))
- except Exception as exc:
- logger.warning(
- "Could not delete DB record for '%s': %s",
- current_name,
- exc,
- )
-
-
-async def _delete_series_db_record_async(current_name: str, expected_name: str) -> None:
- """Async helper to delete series DB record.
-
- Args:
- current_name: The folder name to look up.
- expected_name: The target folder name (for logging).
- """
- async with get_db_session() as db:
- source_series = await AnimeSeriesService.get_by_folder(db, current_name)
- if source_series is None:
- all_series = await AnimeSeriesService.get_all(db)
- for s in all_series:
- if s.folder == current_name:
- source_series = s
- break
- if source_series is not None:
- await AnimeSeriesService.delete(db, source_series.id)
- logger.info(
- "Deleted source DB record for '%s' (id=%s) — target folder '%s' retains DB record",
- current_name,
- source_series.id,
- expected_name,
- )
- else:
- logger.info(
- "No DB record found for source folder '%s' — folder removed only",
- current_name,
- )
-
-
-async def validate_and_rename_series_folders(dry_run: bool = False) -> dict[str, int]:
- """Validate and rename series folders to match NFO metadata.
-
- Iterates over every subfolder in ``settings.anime_directory`` that
- contains a ``tvshow.nfo``. For each folder:
-
- 1. Parse the NFO to extract ```` and ````.
- 2. Compute the expected folder name: ``f"{title} ({year})"``.
- 3. Sanitise the expected name for filesystem safety.
- 4. Compare with the current folder name.
- 5. If different, rename the folder and update the database.
-
- Skips folders where title or year is missing/empty. Logs every
- rename action.
-
- Args:
- dry_run: If True, simulate rename operations without actually
- moving folders or updating the database.
-
- Returns:
- Dictionary with counts:
- - ``"scanned"``: total folders scanned
- - ``"renamed"``: folders renamed
- - ``"skipped"``: folders skipped (missing title/year)
- - ``"errors"``: folders that caused an error
- """
- if not settings.anime_directory:
- logger.warning("Folder rename skipped — anime directory not configured")
- return RenameStats().to_dict()
-
- anime_dir = Path(settings.anime_directory)
- if not anime_dir.is_dir():
- logger.warning("Folder rename skipped — anime directory not found: %s", anime_dir)
- return RenameStats().to_dict()
-
- if dry_run:
- logger.info("Running in DRY-RUN mode — no changes will be made")
-
- stats = RenameStats()
- pre_existing_duplicates: set[str] = set()
- duplicates = _scan_for_pre_existing_duplicates(anime_dir)
-
- for dup_group in duplicates:
- if _try_merge_duplicate_group(dup_group, dry_run=dry_run):
- logger.info(
- "Auto-merged duplicate group for '%s' (%d folders)",
- dup_group.key,
- dup_group.count,
- )
- else:
- for folder in dup_group.folders:
- pre_existing_duplicates.add(folder)
- logger.warning(
- "Duplicate folders detected for series '%s': %s — "
- "manual cleanup required (different releases or non-empty duplicates)",
- dup_group.key,
- dup_group.folders,
- )
-
- for series_dir in sorted(anime_dir.iterdir()):
- if not series_dir.is_dir():
- continue
-
- nfo_path = series_dir / "tvshow.nfo"
- if not nfo_path.exists():
- continue
-
- stats.scanned += 1
-
- title, year = _parse_nfo_title_and_year(nfo_path)
- if not title or not year:
- logger.info(
- "Skipping rename for '%s' — missing title or year in NFO",
- series_dir.name,
- )
- stats.skipped += 1
- continue
-
- expected_name = _compute_expected_folder_name(title, year)
- current_name = series_dir.name
-
- if expected_name == current_name:
- logger.debug("Folder name already correct: '%s'", current_name)
- continue
-
- if _is_series_being_downloaded(current_name):
- logger.info(
- "Skipping rename for '%s' — series has active or pending downloads",
- current_name,
- )
- stats.skipped += 1
- continue
-
- expected_path = anime_dir / expected_name
-
- if current_name in pre_existing_duplicates:
- logger.warning(
- "Skipping rename for '%s' — pre-existing duplicate folder detected",
- current_name,
- )
- stats.errors += 1
- continue
-
- if expected_path.exists():
- if _remove_duplicate_target_folder(series_dir, current_name, expected_name, expected_path):
- stats.renamed += 1
- else:
- stats.errors += 1
- continue
-
- if len(str(expected_path)) > 4096:
- logger.warning(
- "Cannot rename '%s' → '%s' — path exceeds OS limit",
- current_name,
- expected_name,
- )
- stats.errors += 1
- continue
-
- if dry_run:
- logger.info("[DRY-RUN] Would rename folder: '%s' → '%s'", current_name, expected_name)
- stats.renamed += 1
- continue
-
- try:
- old_path = series_dir
- series_dir.rename(expected_path)
- logger.info("Renamed folder: '%s' → '%s'", current_name, expected_name)
- stats.renamed += 1
-
- await _update_database_paths(current_name, expected_name, anime_dir)
- _remove_key_file(expected_path)
- _cleanup_orphaned_folder(old_path, expected_path, dry_run=False)
-
- except PermissionError as exc:
- logger.error(
- "Permission denied renaming '%s' → '%s': %s",
- current_name,
- expected_name,
- exc,
- )
- stats.errors += 1
- except OSError as exc:
- logger.error(
- "OS error renaming '%s' → '%s': %s",
- current_name,
- expected_name,
- exc,
- )
- stats.errors += 1
-
- logger.info(
- "Folder rename scan complete: scanned=%d, renamed=%d, skipped=%d, errors=%d",
- stats.scanned,
- stats.renamed,
- stats.skipped,
- stats.errors,
- )
- return stats.to_dict()
diff --git a/src/server/services/scheduler/folder_scan_service.py b/src/server/services/scheduler/folder_scan_service.py
index 90138dc..63e01e1 100644
--- a/src/server/services/scheduler/folder_scan_service.py
+++ b/src/server/services/scheduler/folder_scan_service.py
@@ -200,22 +200,7 @@ class FolderScanService:
await perform_nfo_repair_scan(background_loader=None)
logger.info("NFO repair scan complete")
- # 1.4 — Validate and rename series folders after NFO repair.
- logger.info("Starting folder rename validation")
- 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.4 — Check and download missing poster.jpg files.
logger.info("Starting poster check")
poster_stats = await self.check_and_download_missing_posters()
logger.info(
diff --git a/tests/integration/test_folder_rename_startup.py b/tests/integration/test_folder_rename_startup.py
deleted file mode 100644
index 9f72538..0000000
--- a/tests/integration/test_folder_rename_startup.py
+++ /dev/null
@@ -1,109 +0,0 @@
-"""Integration tests for folder rename service wiring.
-
-These tests verify that:
-1. FolderScanService.run_folder_scan calls validate_and_rename_series_folders.
-2. The rename logic is properly integrated into the scheduled folder scan.
-"""
-from unittest.mock import AsyncMock, MagicMock, patch
-
-import pytest
-
-
-class TestFolderRenameScanCalledInFolderScan:
- """Verify validate_and_rename_series_folders is invoked from FolderScanService."""
-
- def test_validate_and_rename_imported_in_folder_scan_service(self):
- """folder_scan_service.py imports validate_and_rename_series_folders."""
- import importlib
-
- source = importlib.util.find_spec(
- "src.server.services.scheduler.folder_scan_service"
- ).origin
- with open(source, "r", encoding="utf-8") as fh:
- content = fh.read()
-
- assert "validate_and_rename_series_folders" in content, (
- "validate_and_rename_series_folders must be imported in folder_scan_service.py"
- )
-
- def test_validate_and_rename_called_in_run_folder_scan(self):
- """validate_and_rename_series_folders must be called inside run_folder_scan."""
- import importlib
-
- source = importlib.util.find_spec(
- "src.server.services.scheduler.folder_scan_service"
- ).origin
- with open(source, "r", encoding="utf-8") as fh:
- content = fh.read()
-
- run_folder_scan_pos = content.find("def run_folder_scan")
- rename_call_pos = content.find("validate_and_rename_series_folders()")
-
- assert run_folder_scan_pos != -1, "run_folder_scan method not found"
- assert rename_call_pos != -1, "validate_and_rename_series_folders call not found"
- assert rename_call_pos > run_folder_scan_pos, (
- "validate_and_rename_series_folders must be called INSIDE run_folder_scan"
- )
-
-
-class TestFolderRenameIntegration:
- """Integration test: folder rename is triggered during folder scan."""
-
- @pytest.mark.asyncio
- async def test_folder_rename_runs_during_scan(self, tmp_path):
- """When folder_scan_enabled is true, the scan renames mismatched folders."""
- from src.server.services.scheduler.folder_scan_service import FolderScanService
-
- anime_dir = tmp_path / "anime"
- anime_dir.mkdir()
- series_dir = anime_dir / "Attack on Titan"
- series_dir.mkdir()
- (series_dir / "tvshow.nfo").write_text(
- "Attack on Titan2013"
- )
-
- mock_settings = MagicMock()
- mock_settings.tmdb_api_key = "test-key"
- mock_settings.anime_directory = str(anime_dir)
-
- with patch(
- "src.config.settings.settings", mock_settings
- ), patch(
- "src.server.services.scheduler.folder_rename_service.settings", mock_settings
- ), patch(
- "src.server.services.scheduler.folder_scan_service.perform_nfo_repair_scan",
- new_callable=AsyncMock,
- ), patch(
- "src.server.services.scheduler.folder_rename_service._is_series_being_downloaded",
- return_value=False,
- ), patch(
- "src.server.services.scheduler.folder_rename_service._update_database_paths",
- new_callable=AsyncMock,
- ):
- service = FolderScanService()
- await service.run_folder_scan()
-
- assert not series_dir.exists()
- assert (anime_dir / "Attack on Titan (2013)").is_dir()
-
- @pytest.mark.asyncio
- async def test_folder_rename_skipped_when_prerequisites_not_met(self, tmp_path):
- """If anime directory is missing, rename logic is skipped gracefully."""
- from src.server.services.scheduler.folder_scan_service import FolderScanService
-
- mock_settings = MagicMock()
- mock_settings.tmdb_api_key = "test-key"
- mock_settings.anime_directory = str(tmp_path / "nonexistent")
-
- with patch(
- "src.config.settings.settings", mock_settings
- ), 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"
- ) as mock_rename:
- service = FolderScanService()
- await service.run_folder_scan()
-
- mock_rename.assert_not_called()
diff --git a/tests/integration/test_poster_check_startup.py b/tests/integration/test_poster_check_startup.py
index 1139b4d..fd8190b 100644
--- a/tests/integration/test_poster_check_startup.py
+++ b/tests/integration/test_poster_check_startup.py
@@ -93,10 +93,6 @@ class TestPosterCheckIntegration:
), 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(
"src.server.services.scheduler.folder_scan_service.ImageDownloader",
new=MockDownloader,
@@ -138,10 +134,6 @@ class TestPosterCheckIntegration:
), 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(
"src.server.services.scheduler.folder_scan_service.ImageDownloader"
) as mock_downloader_cls:
@@ -175,10 +167,6 @@ class TestPosterCheckIntegration:
), 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(
"src.server.services.scheduler.folder_scan_service.ImageDownloader"
) as mock_downloader_cls:
@@ -202,8 +190,6 @@ class TestPosterCheckIntegration:
"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"
- ) as mock_rename, patch(
"src.server.services.scheduler.folder_scan_service.ImageDownloader"
) as mock_downloader_cls:
service = FolderScanService()
@@ -272,10 +258,6 @@ class TestPosterCheckSemaphore:
), 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(
"src.server.services.scheduler.folder_scan_service.ImageDownloader"
) as mock_downloader_cls: