chore: sprint cleanup — language unification, parser unification, fossils removal
Several weeks of work accumulated without being committed. Grouped here for clarity; see CHANGELOG.md [Unreleased] for the user-facing summary. Highlights ---------- P1 #2 — ISO 639-2/B canonical migration - New Language VO + LanguageRegistry (alfred/domain/shared/knowledge/). - iso_languages.yaml as single source of truth for language codes. - SubtitleKnowledgeBase now delegates lookup to LanguageRegistry; subtitles.yaml only declares subtitle-specific tokens (vostfr, vf, vff, …). - SubtitlePreferences default → ["fre", "eng"]; subtitle filenames written as {iso639_2b}.srt (legacy fr.srt still read via alias). - Scanner: dropped _LANG_KEYWORDS / _SDH_TOKENS / _FORCED_TOKENS / SUBTITLE_EXTENSIONS hardcoded dicts. - Fixed: 'hi' token no longer marks SDH (conflicted with Hindi alias). - Added settings.min_movie_size_bytes (was a module constant). P1 #3 — Release parser unification + data-driven tokenizer - parse_release() is now the single source of truth for release-name parsing. - alfred/knowledge/release/separators.yaml declares the token separators used by the tokenizer (., space, [, ], (, ), _). New conventions can be added without code changes. - Tokenizer now splits on any configured separator instead of name.split('.'). Releases like 'The Father (2020) [1080p] [WEBRip] [5.1] [YTS.MX]' parse via the direct path without sanitization fallback. - Site-tag extraction always runs first; well-formedness only rejects truly forbidden chars. - _parse_season_episode() extended with NxNN / NxNNxNN alt forms. - Removed dead helpers: _sanitize, _normalize. Domain cleanup - Deleted fossil services with zero production callers: alfred/domain/movies/services.py alfred/domain/tv_shows/services.py alfred/domain/subtitles/services.py (replaced by subtitles/services/ package) alfred/domain/subtitles/repositories.py - Split monolithic subtitle services into a package (identifier, matcher, placer, pattern_detector, utils) + dedicated knowledge/ package. - MediaInfo split into dedicated package (alfred/domain/shared/media/: audio, video, subtitle, info, matching). Persistence cleanup - Removed dead JSON repositories (movie/subtitle/tvshow_repository.py). Tests - Major expansion of the test suite organized to mirror the source tree. - Removed obsolete *_edge_cases test files superseded by structured tests. - Suite: 990 passed, 8 skipped. Misc - .gitignore: exclude env_backup/ and *.bak. - Adjustments across agent/llm, app.py, application/filesystem, and infrastructure/filesystem to align with the new domain layout.
This commit is contained in:
@@ -1,322 +1,396 @@
|
||||
"""
|
||||
Tests for alfred.application.filesystem.resolve_destination
|
||||
"""Tests for ``alfred.application.filesystem.resolve_destination``.
|
||||
|
||||
Uses a real temp filesystem + a real Memory instance (via conftest fixtures).
|
||||
No network calls — TMDB data is passed in directly.
|
||||
Four use cases compute library paths from a release name + TMDB metadata:
|
||||
|
||||
- ``resolve_season_destination`` — folder move (series + season).
|
||||
- ``resolve_episode_destination`` — file move (full library_file path).
|
||||
- ``resolve_movie_destination`` — file move (folder + library_file).
|
||||
- ``resolve_series_destination`` — folder move (whole multi-season pack).
|
||||
|
||||
Coverage:
|
||||
|
||||
- ``TestSanitize`` — Windows-forbidden chars stripped.
|
||||
- ``TestFindExistingTvshowFolders`` — empty root, prefix match (case + space → dot).
|
||||
- ``TestResolveSeriesFolderInternal`` — confirmed_folder, no existing, single match,
|
||||
ambiguous → _Clarification.
|
||||
- ``TestSeason`` — library_not_set, ok path, clarification path.
|
||||
- ``TestEpisode`` — library_not_set, ok path, filename includes episode_title, ext from source.
|
||||
- ``TestMovie`` — library_not_set, ok path, is_new_folder, sanitization.
|
||||
- ``TestSeries`` — library_not_set, ok path.
|
||||
- ``TestDTOToDict`` — each DTO's three states (ok / clarification / error).
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from alfred.application.filesystem.resolve_destination import (
|
||||
ResolveDestinationUseCase,
|
||||
_find_existing_series_folders,
|
||||
ResolvedEpisodeDestination,
|
||||
ResolvedMovieDestination,
|
||||
ResolvedSeasonDestination,
|
||||
ResolvedSeriesDestination,
|
||||
_Clarification,
|
||||
_find_existing_tvshow_folders,
|
||||
_resolve_series_folder,
|
||||
_sanitize,
|
||||
resolve_episode_destination,
|
||||
resolve_movie_destination,
|
||||
resolve_season_destination,
|
||||
resolve_series_destination,
|
||||
)
|
||||
from alfred.infrastructure.persistence import Memory, set_memory
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
REL_EPISODE = "Oz.S01E01.1080p.WEBRip.x265-KONTRAST"
|
||||
REL_SEASON = "Oz.S03.1080p.WEBRip.x265-KONTRAST"
|
||||
REL_MOVIE = "Inception.2010.1080p.BluRay.x265-GROUP"
|
||||
REL_SERIES = "Oz.Complete.Series.1080p.WEBRip.x265-KONTRAST"
|
||||
|
||||
|
||||
def _use_case():
|
||||
return ResolveDestinationUseCase()
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Helpers #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Movies
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestSanitize:
|
||||
def test_passthrough_safe_chars(self):
|
||||
assert _sanitize("Oz.1997.1080p-GRP") == "Oz.1997.1080p-GRP"
|
||||
|
||||
def test_strips_windows_forbidden(self):
|
||||
# ? : * " < > | \
|
||||
assert _sanitize('a?b:c*d"e<f>g|h\\i') == "abcdefghi"
|
||||
|
||||
|
||||
class TestResolveMovie:
|
||||
def test_basic_movie(self, memory_configured):
|
||||
result = _use_case().execute(
|
||||
release_name="Another.Round.2020.1080p.BluRay.x264-YTS",
|
||||
source_file="/downloads/Another.Round.2020.1080p.BluRay.x264-YTS/Another.Round.2020.1080p.BluRay.x264-YTS.mp4",
|
||||
tmdb_title="Another Round",
|
||||
tmdb_year=2020,
|
||||
# --------------------------------------------------------------------------- #
|
||||
# _find_existing_tvshow_folders #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestFindExistingTvshowFolders:
|
||||
def test_missing_root_returns_empty(self, tmp_path):
|
||||
assert _find_existing_tvshow_folders(tmp_path / "ghost", "Oz", 1997) == []
|
||||
|
||||
def test_no_match(self, tmp_path):
|
||||
(tmp_path / "OtherShow.1999").mkdir()
|
||||
assert _find_existing_tvshow_folders(tmp_path, "Oz", 1997) == []
|
||||
|
||||
def test_matches_prefix_case_insensitive_with_space_dot(self, tmp_path):
|
||||
(tmp_path / "Oz.1997.WEBRip-KONTRAST").mkdir()
|
||||
(tmp_path / "oz.1997.bluray-OTHER").mkdir()
|
||||
(tmp_path / "OtherShow.1999").mkdir()
|
||||
out = _find_existing_tvshow_folders(tmp_path, "Oz", 1997)
|
||||
assert out == ["Oz.1997.WEBRip-KONTRAST", "oz.1997.bluray-OTHER"] or set(out) == {
|
||||
"Oz.1997.WEBRip-KONTRAST",
|
||||
"oz.1997.bluray-OTHER",
|
||||
}
|
||||
|
||||
def test_files_ignored(self, tmp_path):
|
||||
(tmp_path / "Oz.1997.txt").write_text("not a folder")
|
||||
assert _find_existing_tvshow_folders(tmp_path, "Oz", 1997) == []
|
||||
|
||||
def test_space_in_title_becomes_dot(self, tmp_path):
|
||||
(tmp_path / "The.X.Files.1993.x265-KONTRAST").mkdir()
|
||||
assert _find_existing_tvshow_folders(tmp_path, "The X Files", 1993) == [
|
||||
"The.X.Files.1993.x265-KONTRAST"
|
||||
]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# _resolve_series_folder #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestResolveSeriesFolderInternal:
|
||||
def test_confirmed_folder_when_exists(self, tmp_path):
|
||||
(tmp_path / "Oz.1997.X-GRP").mkdir()
|
||||
out = _resolve_series_folder(
|
||||
tmp_path, "Oz", 1997, "Oz.1997.WEBRip-KONTRAST", confirmed_folder="Oz.1997.X-GRP"
|
||||
)
|
||||
assert result.status == "ok"
|
||||
assert "Another.Round.2020" in result.series_folder_name
|
||||
assert "1080p.BluRay.x264-YTS" in result.series_folder_name
|
||||
assert result.filename.endswith(".mp4")
|
||||
assert result.season_folder is None
|
||||
assert out == ("Oz.1997.X-GRP", False)
|
||||
|
||||
def test_movie_library_file_path_is_inside_series_folder(self, memory_configured):
|
||||
result = _use_case().execute(
|
||||
release_name="Revolver.2005.1080p.BluRay.x265-RARBG",
|
||||
source_file="/downloads/Revolver.2005.1080p.BluRay.x265-RARBG.mkv",
|
||||
tmdb_title="Revolver",
|
||||
tmdb_year=2005,
|
||||
def test_confirmed_folder_when_new(self, tmp_path):
|
||||
out = _resolve_series_folder(
|
||||
tmp_path, "Oz", 1997, "Oz.1997.WEBRip-KONTRAST", confirmed_folder="Oz.1997.New-X"
|
||||
)
|
||||
assert result.status == "ok"
|
||||
assert result.library_file.startswith(result.series_folder)
|
||||
assert out == ("Oz.1997.New-X", True)
|
||||
|
||||
def test_movie_library_not_set(self, memory):
|
||||
# memory has no library paths configured
|
||||
result = _use_case().execute(
|
||||
release_name="Revolver.2005.1080p.BluRay.x265-RARBG",
|
||||
source_file="/downloads/Revolver.2005.1080p.BluRay.x265-RARBG.mkv",
|
||||
tmdb_title="Revolver",
|
||||
tmdb_year=2005,
|
||||
)
|
||||
assert result.status == "error"
|
||||
assert result.error == "library_not_set"
|
||||
def test_no_existing_returns_computed_as_new(self, tmp_path):
|
||||
out = _resolve_series_folder(tmp_path, "Oz", 1997, "Oz.1997.WEBRip-KONTRAST", None)
|
||||
assert out == ("Oz.1997.WEBRip-KONTRAST", True)
|
||||
|
||||
def test_movie_folder_marked_new(self, memory_configured):
|
||||
# No existing folder → is_new_series_folder = True
|
||||
result = _use_case().execute(
|
||||
release_name="Godzilla.Minus.One.2023.1080p.BluRay.x265-YTS",
|
||||
source_file="/downloads/Godzilla.Minus.One.2023.1080p.BluRay.x265-YTS.mp4",
|
||||
tmdb_title="Godzilla Minus One",
|
||||
tmdb_year=2023,
|
||||
)
|
||||
assert result.status == "ok"
|
||||
assert result.is_new_series_folder is True
|
||||
def test_single_existing_matching_computed_returns_existing(self, tmp_path):
|
||||
(tmp_path / "Oz.1997.WEBRip-KONTRAST").mkdir()
|
||||
out = _resolve_series_folder(tmp_path, "Oz", 1997, "Oz.1997.WEBRip-KONTRAST", None)
|
||||
assert out == ("Oz.1997.WEBRip-KONTRAST", False)
|
||||
|
||||
def test_movie_sanitises_forbidden_chars_in_title(self, memory_configured):
|
||||
result = _use_case().execute(
|
||||
release_name="Alien.Earth.2024.1080p.WEBRip.x265-KONTRAST",
|
||||
source_file="/downloads/Alien.Earth.2024.1080p.WEBRip.x265-KONTRAST.mkv",
|
||||
tmdb_title="Alien: Earth",
|
||||
tmdb_year=2024,
|
||||
)
|
||||
assert result.status == "ok"
|
||||
assert ":" not in result.series_folder_name
|
||||
def test_single_existing_different_name_returns_clarification(self, tmp_path):
|
||||
(tmp_path / "Oz.1997.BluRay-OTHER").mkdir()
|
||||
out = _resolve_series_folder(tmp_path, "Oz", 1997, "Oz.1997.WEBRip-KONTRAST", None)
|
||||
assert isinstance(out, _Clarification)
|
||||
assert "Oz" in out.question
|
||||
assert "Oz.1997.BluRay-OTHER" in out.options
|
||||
assert "Oz.1997.WEBRip-KONTRAST" in out.options
|
||||
|
||||
def test_to_dict_ok(self, memory_configured):
|
||||
result = _use_case().execute(
|
||||
release_name="Revolver.2005.1080p.BluRay.x265-RARBG",
|
||||
source_file="/downloads/Revolver.mkv",
|
||||
tmdb_title="Revolver",
|
||||
tmdb_year=2005,
|
||||
def test_multiple_existing_returns_clarification(self, tmp_path):
|
||||
(tmp_path / "Oz.1997.A-GRP").mkdir()
|
||||
(tmp_path / "Oz.1997.B-GRP").mkdir()
|
||||
out = _resolve_series_folder(tmp_path, "Oz", 1997, "Oz.1997.A-GRP", None)
|
||||
assert isinstance(out, _Clarification)
|
||||
# Computed already in existing → not duplicated.
|
||||
assert out.options.count("Oz.1997.A-GRP") == 1
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Season #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cfg_memory(tmp_path):
|
||||
"""Memory with tv_show + movie roots inside tmp_path. Roots NOT auto-created."""
|
||||
storage = tmp_path / "_mem"
|
||||
storage.mkdir()
|
||||
tv = tmp_path / "tv"
|
||||
mv = tmp_path / "mv"
|
||||
tv.mkdir()
|
||||
mv.mkdir()
|
||||
mem = Memory(storage_dir=str(storage))
|
||||
set_memory(mem)
|
||||
mem.ltm.library_paths.set("tv_show", str(tv))
|
||||
mem.ltm.library_paths.set("movie", str(mv))
|
||||
mem.save()
|
||||
return mem, tv, mv
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def empty_memory(tmp_path):
|
||||
"""Memory with no library_paths configured."""
|
||||
storage = tmp_path / "_mem_empty"
|
||||
storage.mkdir()
|
||||
mem = Memory(storage_dir=str(storage))
|
||||
set_memory(mem)
|
||||
return mem
|
||||
|
||||
|
||||
class TestSeason:
|
||||
def test_library_not_set(self, empty_memory):
|
||||
out = resolve_season_destination(REL_SEASON, "Oz", 1997)
|
||||
assert out.status == "error"
|
||||
assert out.error == "library_not_set"
|
||||
|
||||
def test_ok_path_new_series(self, cfg_memory):
|
||||
_, tv, _ = cfg_memory
|
||||
out = resolve_season_destination(REL_SEASON, "Oz", 1997)
|
||||
assert out.status == "ok"
|
||||
assert out.is_new_series_folder is True
|
||||
assert out.series_folder_name.startswith("Oz.1997")
|
||||
assert out.season_folder_name.startswith("Oz.S03")
|
||||
assert out.series_folder == str(tv / out.series_folder_name)
|
||||
assert out.season_folder == str(tv / out.series_folder_name / out.season_folder_name)
|
||||
|
||||
def test_clarification_path(self, cfg_memory):
|
||||
_, tv, _ = cfg_memory
|
||||
(tv / "Oz.1997.BluRay-OTHER").mkdir()
|
||||
out = resolve_season_destination(REL_SEASON, "Oz", 1997)
|
||||
assert out.status == "needs_clarification"
|
||||
assert out.options
|
||||
assert any("Oz" in o for o in out.options)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Episode #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestEpisode:
|
||||
def test_library_not_set(self, empty_memory):
|
||||
out = resolve_episode_destination(REL_EPISODE, "/in/x.mkv", "Oz", 1997)
|
||||
assert out.status == "error"
|
||||
assert out.error == "library_not_set"
|
||||
|
||||
def test_ok_path_with_episode_title(self, cfg_memory):
|
||||
_, tv, _ = cfg_memory
|
||||
out = resolve_episode_destination(
|
||||
REL_EPISODE, "/dl/source.mkv", "Oz", 1997, tmdb_episode_title="The Routine"
|
||||
)
|
||||
d = result.to_dict()
|
||||
assert out.status == "ok"
|
||||
assert out.filename.endswith(".mkv")
|
||||
assert "S01E01" in out.filename
|
||||
assert "The.Routine" in out.filename
|
||||
# library_file is series/season/file
|
||||
assert out.library_file == str(
|
||||
tv / out.series_folder_name / out.season_folder_name / out.filename
|
||||
)
|
||||
|
||||
def test_ok_path_without_episode_title(self, cfg_memory):
|
||||
out = resolve_episode_destination(
|
||||
REL_EPISODE, "/dl/source.mkv", "Oz", 1997
|
||||
)
|
||||
assert out.status == "ok"
|
||||
# No '..' from blank ep title.
|
||||
assert ".." not in out.filename
|
||||
|
||||
def test_extension_taken_from_source_file(self, cfg_memory):
|
||||
out = resolve_episode_destination(
|
||||
REL_EPISODE, "/dl/source.mp4", "Oz", 1997
|
||||
)
|
||||
assert out.filename.endswith(".mp4")
|
||||
|
||||
def test_clarification_path(self, cfg_memory):
|
||||
_, tv, _ = cfg_memory
|
||||
(tv / "Oz.1997.BluRay-OTHER").mkdir()
|
||||
out = resolve_episode_destination(
|
||||
REL_EPISODE, "/dl/source.mkv", "Oz", 1997
|
||||
)
|
||||
assert out.status == "needs_clarification"
|
||||
|
||||
def test_confirmed_folder_threaded_through(self, cfg_memory):
|
||||
_, tv, _ = cfg_memory
|
||||
(tv / "Oz.1997.BluRay-OTHER").mkdir()
|
||||
out = resolve_episode_destination(
|
||||
REL_EPISODE, "/dl/source.mkv", "Oz", 1997,
|
||||
confirmed_folder="Oz.1997.BluRay-OTHER",
|
||||
)
|
||||
assert out.status == "ok"
|
||||
assert out.series_folder_name == "Oz.1997.BluRay-OTHER"
|
||||
assert out.is_new_series_folder is False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Movie #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestMovie:
|
||||
def test_library_not_set(self, empty_memory):
|
||||
out = resolve_movie_destination(REL_MOVIE, "/dl/m.mkv", "Inception", 2010)
|
||||
assert out.status == "error"
|
||||
assert out.error == "library_not_set"
|
||||
|
||||
def test_ok_path(self, cfg_memory):
|
||||
_, _, mv = cfg_memory
|
||||
out = resolve_movie_destination(REL_MOVIE, "/dl/m.mkv", "Inception", 2010)
|
||||
assert out.status == "ok"
|
||||
assert out.movie_folder_name.startswith("Inception.2010")
|
||||
assert out.filename.endswith(".mkv")
|
||||
assert out.movie_folder == str(mv / out.movie_folder_name)
|
||||
assert out.library_file == str(mv / out.movie_folder_name / out.filename)
|
||||
assert out.is_new_folder is True
|
||||
|
||||
def test_is_new_folder_false_when_exists(self, cfg_memory):
|
||||
_, _, mv = cfg_memory
|
||||
out_first = resolve_movie_destination(REL_MOVIE, "/dl/m.mkv", "Inception", 2010)
|
||||
(mv / out_first.movie_folder_name).mkdir()
|
||||
out = resolve_movie_destination(REL_MOVIE, "/dl/m.mkv", "Inception", 2010)
|
||||
assert out.is_new_folder is False
|
||||
|
||||
def test_title_sanitized(self, cfg_memory):
|
||||
# Title with forbidden chars should be stripped.
|
||||
out = resolve_movie_destination(REL_MOVIE, "/dl/m.mkv", "Foo:Bar", 2010)
|
||||
assert ":" not in out.movie_folder_name
|
||||
assert ":" not in out.filename
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Series #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestSeries:
|
||||
def test_library_not_set(self, empty_memory):
|
||||
out = resolve_series_destination(REL_SERIES, "Oz", 1997)
|
||||
assert out.status == "error"
|
||||
assert out.error == "library_not_set"
|
||||
|
||||
def test_ok_path(self, cfg_memory):
|
||||
_, tv, _ = cfg_memory
|
||||
out = resolve_series_destination(REL_SERIES, "Oz", 1997)
|
||||
assert out.status == "ok"
|
||||
assert out.series_folder_name.startswith("Oz.1997")
|
||||
assert out.series_folder == str(tv / out.series_folder_name)
|
||||
assert out.is_new_series_folder is True
|
||||
|
||||
def test_clarification_path(self, cfg_memory):
|
||||
_, tv, _ = cfg_memory
|
||||
(tv / "Oz.1997.X-GRP").mkdir()
|
||||
out = resolve_series_destination(REL_SERIES, "Oz", 1997)
|
||||
assert out.status == "needs_clarification"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# DTO to_dict() #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestDTOToDict:
|
||||
def test_season_ok(self):
|
||||
d = ResolvedSeasonDestination(
|
||||
status="ok",
|
||||
series_folder="/tv/S",
|
||||
season_folder="/tv/S/Season",
|
||||
series_folder_name="S",
|
||||
season_folder_name="Season",
|
||||
is_new_series_folder=True,
|
||||
).to_dict()
|
||||
assert d["status"] == "ok"
|
||||
assert "library_file" in d
|
||||
assert "series_folder_name" in d
|
||||
assert d["series_folder"] == "/tv/S"
|
||||
assert d["season_folder"] == "/tv/S/Season"
|
||||
assert d["is_new_series_folder"] is True
|
||||
|
||||
def test_season_error(self):
|
||||
d = ResolvedSeasonDestination(
|
||||
status="error", error="library_not_set", message="missing"
|
||||
).to_dict()
|
||||
assert d == {"status": "error", "error": "library_not_set", "message": "missing"}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TV shows — no existing folder
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_season_clarification(self):
|
||||
d = ResolvedSeasonDestination(
|
||||
status="needs_clarification", question="which?", options=["a", "b"]
|
||||
).to_dict()
|
||||
assert d == {"status": "needs_clarification", "question": "which?", "options": ["a", "b"]}
|
||||
|
||||
def test_episode_ok(self):
|
||||
d = ResolvedEpisodeDestination(
|
||||
status="ok",
|
||||
series_folder="/tv/S",
|
||||
season_folder="/tv/S/Season",
|
||||
library_file="/tv/S/Season/X.mkv",
|
||||
series_folder_name="S",
|
||||
season_folder_name="Season",
|
||||
filename="X.mkv",
|
||||
is_new_series_folder=False,
|
||||
).to_dict()
|
||||
assert d["library_file"] == "/tv/S/Season/X.mkv"
|
||||
assert d["filename"] == "X.mkv"
|
||||
|
||||
class TestResolveTVShowNewFolder:
|
||||
def test_oz_s01_creates_new_folder(self, memory_configured):
|
||||
result = _use_case().execute(
|
||||
release_name="Oz.S01.1080p.WEBRip.x265-KONTRAST",
|
||||
source_file="/downloads/Oz.S01.1080p.WEBRip.x265-KONTRAST/Oz.S01E01.1080p.WEBRip.x265-KONTRAST.mp4",
|
||||
tmdb_title="Oz",
|
||||
tmdb_year=1997,
|
||||
)
|
||||
assert result.status == "ok"
|
||||
assert result.is_new_series_folder is True
|
||||
assert result.series_folder_name == "Oz.1997.1080p.WEBRip.x265-KONTRAST"
|
||||
assert result.season_folder_name == "Oz.S01.1080p.WEBRip.x265-KONTRAST"
|
||||
def test_movie_ok(self):
|
||||
d = ResolvedMovieDestination(
|
||||
status="ok",
|
||||
movie_folder="/mv/X",
|
||||
library_file="/mv/X/X.mkv",
|
||||
movie_folder_name="X",
|
||||
filename="X.mkv",
|
||||
is_new_folder=True,
|
||||
).to_dict()
|
||||
assert d["movie_folder"] == "/mv/X"
|
||||
assert d["library_file"] == "/mv/X/X.mkv"
|
||||
assert d["is_new_folder"] is True
|
||||
|
||||
def test_tv_library_not_set(self, memory):
|
||||
result = _use_case().execute(
|
||||
release_name="Oz.S01.1080p.WEBRip.x265-KONTRAST",
|
||||
source_file="/downloads/Oz.S01E01.mp4",
|
||||
tmdb_title="Oz",
|
||||
tmdb_year=1997,
|
||||
)
|
||||
assert result.status == "error"
|
||||
assert result.error == "library_not_set"
|
||||
def test_series_ok(self):
|
||||
d = ResolvedSeriesDestination(
|
||||
status="ok",
|
||||
series_folder="/tv/S",
|
||||
series_folder_name="S",
|
||||
is_new_series_folder=False,
|
||||
).to_dict()
|
||||
assert d == {
|
||||
"status": "ok",
|
||||
"series_folder": "/tv/S",
|
||||
"series_folder_name": "S",
|
||||
"is_new_series_folder": False,
|
||||
}
|
||||
|
||||
def test_single_episode_filename(self, memory_configured):
|
||||
result = _use_case().execute(
|
||||
release_name="Fallout.2024.S02E01.1080p.x265-ELiTE",
|
||||
source_file="/downloads/Fallout.2024.S02E01.1080p.x265-ELiTE.mkv",
|
||||
tmdb_title="Fallout",
|
||||
tmdb_year=2024,
|
||||
tmdb_episode_title="The Beginning",
|
||||
)
|
||||
assert result.status == "ok"
|
||||
assert "S02E01" in result.filename
|
||||
assert "The.Beginning" in result.filename
|
||||
assert result.filename.endswith(".mkv")
|
||||
|
||||
def test_season_pack_filename_is_folder_name_plus_ext(self, memory_configured):
|
||||
result = _use_case().execute(
|
||||
release_name="Oz.S01.1080p.WEBRip.x265-KONTRAST",
|
||||
source_file="/downloads/Oz.S01.1080p.WEBRip.x265-KONTRAST/Oz.S01E01.mp4",
|
||||
tmdb_title="Oz",
|
||||
tmdb_year=1997,
|
||||
)
|
||||
assert result.status == "ok"
|
||||
# Season pack: filename = season_folder_name + ext
|
||||
assert result.filename == result.season_folder_name + ".mp4"
|
||||
|
||||
def test_library_file_is_inside_season_folder(self, memory_configured):
|
||||
result = _use_case().execute(
|
||||
release_name="Oz.S01.1080p.WEBRip.x265-KONTRAST",
|
||||
source_file="/downloads/Oz.S01E01.mp4",
|
||||
tmdb_title="Oz",
|
||||
tmdb_year=1997,
|
||||
)
|
||||
assert result.library_file.startswith(result.season_folder)
|
||||
assert result.season_folder.startswith(result.series_folder)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TV shows — existing folder matching
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResolveTVShowExistingFolder:
|
||||
def _make_series_folder(self, tv_root, name):
|
||||
"""Create a series folder in the tv library."""
|
||||
path = tv_root / name
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
def test_uses_existing_single_folder(self, memory_configured, app_temp):
|
||||
"""When exactly one folder matches title+year, use it regardless of group."""
|
||||
from alfred.infrastructure.persistence import get_memory
|
||||
|
||||
mem = get_memory()
|
||||
tv_root = Path(mem.ltm.library_paths.get("tv_show"))
|
||||
|
||||
existing = tv_root / "Oz.1997.1080p.WEBRip.x265-RARBG"
|
||||
existing.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
result = _use_case().execute(
|
||||
release_name="Oz.S02.1080p.WEBRip.x265-KONTRAST",
|
||||
source_file="/downloads/Oz.S02E01.mp4",
|
||||
tmdb_title="Oz",
|
||||
tmdb_year=1997,
|
||||
)
|
||||
assert result.status == "ok"
|
||||
assert result.series_folder_name == "Oz.1997.1080p.WEBRip.x265-RARBG"
|
||||
assert result.is_new_series_folder is False
|
||||
|
||||
def test_needs_clarification_on_multiple_folders(self, memory_configured, app_temp):
|
||||
"""When multiple folders match, return needs_clarification with options."""
|
||||
from alfred.infrastructure.persistence import get_memory
|
||||
|
||||
mem = get_memory()
|
||||
tv_root = Path(mem.ltm.library_paths.get("tv_show"))
|
||||
|
||||
(tv_root / "Slow.Horses.2022.1080p.WEBRip.x265-RARBG").mkdir(
|
||||
parents=True, exist_ok=True
|
||||
)
|
||||
(tv_root / "Slow.Horses.2022.1080p.WEBRip.x265-KONTRAST").mkdir(
|
||||
parents=True, exist_ok=True
|
||||
)
|
||||
|
||||
result = _use_case().execute(
|
||||
release_name="Slow.Horses.S05.1080p.WEBRip.x265-KONTRAST",
|
||||
source_file="/downloads/Slow.Horses.S05E01.mkv",
|
||||
tmdb_title="Slow Horses",
|
||||
tmdb_year=2022,
|
||||
)
|
||||
assert result.status == "needs_clarification"
|
||||
assert result.question is not None
|
||||
assert len(result.options) == 2
|
||||
assert "Slow.Horses.2022.1080p.WEBRip.x265-RARBG" in result.options
|
||||
assert "Slow.Horses.2022.1080p.WEBRip.x265-KONTRAST" in result.options
|
||||
|
||||
def test_confirmed_folder_bypasses_detection(self, memory_configured, app_temp):
|
||||
"""confirmed_folder skips the folder search."""
|
||||
from alfred.infrastructure.persistence import get_memory
|
||||
|
||||
mem = get_memory()
|
||||
tv_root = Path(mem.ltm.library_paths.get("tv_show"))
|
||||
chosen = "Slow.Horses.2022.1080p.WEBRip.x265-RARBG"
|
||||
(tv_root / chosen).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
result = _use_case().execute(
|
||||
release_name="Slow.Horses.S05.1080p.WEBRip.x265-KONTRAST",
|
||||
source_file="/downloads/Slow.Horses.S05E01.mkv",
|
||||
tmdb_title="Slow Horses",
|
||||
tmdb_year=2022,
|
||||
confirmed_folder=chosen,
|
||||
)
|
||||
assert result.status == "ok"
|
||||
assert result.series_folder_name == chosen
|
||||
|
||||
def test_to_dict_needs_clarification(self, memory_configured, app_temp):
|
||||
from alfred.infrastructure.persistence import get_memory
|
||||
|
||||
mem = get_memory()
|
||||
tv_root = Path(mem.ltm.library_paths.get("tv_show"))
|
||||
(tv_root / "Oz.1997.1080p.WEBRip.x265-RARBG").mkdir(parents=True, exist_ok=True)
|
||||
(tv_root / "Oz.1997.1080p.WEBRip.x265-KONTRAST").mkdir(
|
||||
parents=True, exist_ok=True
|
||||
)
|
||||
|
||||
result = _use_case().execute(
|
||||
release_name="Oz.S03.1080p.WEBRip.x265-KONTRAST",
|
||||
source_file="/downloads/Oz.S03E01.mp4",
|
||||
tmdb_title="Oz",
|
||||
tmdb_year=1997,
|
||||
)
|
||||
d = result.to_dict()
|
||||
assert d["status"] == "needs_clarification"
|
||||
assert "question" in d
|
||||
assert isinstance(d["options"], list)
|
||||
|
||||
def test_to_dict_error(self, memory):
|
||||
result = _use_case().execute(
|
||||
release_name="Oz.S01.1080p.WEBRip.x265-KONTRAST",
|
||||
source_file="/downloads/Oz.S01E01.mp4",
|
||||
tmdb_title="Oz",
|
||||
tmdb_year=1997,
|
||||
)
|
||||
d = result.to_dict()
|
||||
assert d["status"] == "error"
|
||||
assert "error" in d
|
||||
assert "message" in d
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _find_existing_series_folders
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFindExistingSeriesFolders:
|
||||
def test_empty_library(self, tmp_path):
|
||||
assert _find_existing_series_folders(tmp_path, "Oz", 1997) == []
|
||||
|
||||
def test_nonexistent_root(self, tmp_path):
|
||||
assert _find_existing_series_folders(tmp_path / "nope", "Oz", 1997) == []
|
||||
|
||||
def test_single_match(self, tmp_path):
|
||||
(tmp_path / "Oz.1997.1080p.WEBRip.x265-KONTRAST").mkdir()
|
||||
result = _find_existing_series_folders(tmp_path, "Oz", 1997)
|
||||
assert result == ["Oz.1997.1080p.WEBRip.x265-KONTRAST"]
|
||||
|
||||
def test_multiple_matches(self, tmp_path):
|
||||
(tmp_path / "Oz.1997.1080p.WEBRip.x265-KONTRAST").mkdir()
|
||||
(tmp_path / "Oz.1997.1080p.WEBRip.x265-RARBG").mkdir()
|
||||
result = _find_existing_series_folders(tmp_path, "Oz", 1997)
|
||||
assert len(result) == 2
|
||||
assert sorted(result) == result # sorted
|
||||
|
||||
def test_no_match_different_year(self, tmp_path):
|
||||
(tmp_path / "Oz.1997.1080p.WEBRip.x265-KONTRAST").mkdir()
|
||||
result = _find_existing_series_folders(tmp_path, "Oz", 2000)
|
||||
assert result == []
|
||||
|
||||
def test_no_match_different_title(self, tmp_path):
|
||||
(tmp_path / "Oz.1997.1080p.WEBRip.x265-KONTRAST").mkdir()
|
||||
result = _find_existing_series_folders(tmp_path, "Breaking Bad", 2008)
|
||||
assert result == []
|
||||
|
||||
def test_ignores_files_not_dirs(self, tmp_path):
|
||||
(tmp_path / "Oz.1997.1080p.WEBRip.x265-KONTRAST").mkdir()
|
||||
(tmp_path / "Oz.1997.some.file.txt").touch()
|
||||
result = _find_existing_series_folders(tmp_path, "Oz", 1997)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_case_insensitive_prefix(self, tmp_path):
|
||||
# Folder stored with mixed case
|
||||
(tmp_path / "OZ.1997.1080p.WEBRip.x265-KONTRAST").mkdir()
|
||||
result = _find_existing_series_folders(tmp_path, "Oz", 1997)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_title_with_special_chars_sanitised(self, tmp_path):
|
||||
# "Star Wars: Andor" → sanitised (colon removed) + spaces→dots → "Star.Wars.Andor.2022"
|
||||
(tmp_path / "Star.Wars.Andor.2022.1080p.WEBRip.x265-GROUP").mkdir()
|
||||
result = _find_existing_series_folders(tmp_path, "Star Wars: Andor", 2022)
|
||||
assert len(result) == 1
|
||||
def test_clarification_options_none_yields_empty_list(self):
|
||||
d = ResolvedSeasonDestination(
|
||||
status="needs_clarification", question="q", options=None
|
||||
).to_dict()
|
||||
assert d["options"] == []
|
||||
|
||||
Reference in New Issue
Block a user