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:
@@ -0,0 +1,111 @@
|
||||
"""Tests for ``alfred.application.torrents.add_torrent.AddTorrentUseCase``.
|
||||
|
||||
Wraps ``QBittorrentClient.add_torrent`` with magnet-link validation and
|
||||
exception translation into an ``AddTorrentResponse`` envelope.
|
||||
|
||||
Coverage:
|
||||
|
||||
- ``TestValidation`` — empty / non-string / non-magnet rejection.
|
||||
- ``TestSuccess`` — client returns True → status="ok".
|
||||
- ``TestAddFailure`` — client returns False → status="error", error="add_failed".
|
||||
- ``TestErrorTranslation`` — ``QBittorrentAuthError`` → authentication_failed,
|
||||
``QBittorrentAPIError`` → api_error.
|
||||
|
||||
QBittorrentClient is fully mocked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from alfred.application.torrents.add_torrent import AddTorrentUseCase
|
||||
from alfred.infrastructure.api.qbittorrent.exceptions import (
|
||||
QBittorrentAPIError,
|
||||
QBittorrentAuthError,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def use_case(client):
|
||||
return AddTorrentUseCase(client)
|
||||
|
||||
|
||||
VALID_MAGNET = "magnet:?xt=urn:btih:abc"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Validation #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestValidation:
|
||||
@pytest.mark.parametrize("bad", ["", None, 42, b"magnet:?x"])
|
||||
def test_invalid_inputs_return_validation_failed(self, use_case, client, bad):
|
||||
r = use_case.execute(bad)
|
||||
assert r.status == "error"
|
||||
assert r.error == "validation_failed"
|
||||
client.add_torrent.assert_not_called()
|
||||
|
||||
def test_non_magnet_scheme_rejected(self, use_case, client):
|
||||
r = use_case.execute("http://example.com/torrent")
|
||||
assert r.status == "error"
|
||||
assert r.error == "validation_failed"
|
||||
assert "magnet" in r.message.lower()
|
||||
client.add_torrent.assert_not_called()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Success #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestSuccess:
|
||||
def test_add_success(self, use_case, client):
|
||||
client.add_torrent.return_value = True
|
||||
r = use_case.execute(VALID_MAGNET)
|
||||
assert r.status == "ok"
|
||||
assert r.error is None
|
||||
assert "success" in r.message.lower()
|
||||
client.add_torrent.assert_called_once_with(VALID_MAGNET)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Add failure #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestAddFailure:
|
||||
def test_add_returns_false(self, use_case, client):
|
||||
client.add_torrent.return_value = False
|
||||
r = use_case.execute(VALID_MAGNET)
|
||||
assert r.status == "error"
|
||||
assert r.error == "add_failed"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Error translation #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestErrorTranslation:
|
||||
def test_auth_error_translated(self, use_case, client):
|
||||
client.add_torrent.side_effect = QBittorrentAuthError("bad creds")
|
||||
r = use_case.execute(VALID_MAGNET)
|
||||
assert r.status == "error"
|
||||
assert r.error == "authentication_failed"
|
||||
# The message is a fixed user-facing string, not the raw exception.
|
||||
assert "authenticate" in r.message.lower()
|
||||
|
||||
def test_api_error_translated(self, use_case, client):
|
||||
client.add_torrent.side_effect = QBittorrentAPIError("server down")
|
||||
r = use_case.execute(VALID_MAGNET)
|
||||
assert r.status == "error"
|
||||
assert r.error == "api_error"
|
||||
assert "server down" in r.message
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Tests for ``alfred.application.filesystem.detect_media_type``.
|
||||
|
||||
The function refines a ``ParsedRelease.media_type`` using filesystem evidence.
|
||||
|
||||
Coverage:
|
||||
|
||||
- ``TestFile`` — single-file source (.mkv / .iso / .nfo-only).
|
||||
- ``TestFolder`` — first-level folder scan; mixed/video-only/non-video-only.
|
||||
- ``TestMetadataIgnored`` — ``.nfo``, ``.srt``, ``.jpg`` never tip the decision.
|
||||
- ``TestMissing`` — non-existent paths fall through to parsed.media_type.
|
||||
|
||||
No mocking — pure function over a real ``tmp_path``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from alfred.application.filesystem.detect_media_type import detect_media_type
|
||||
from alfred.domain.release.services import parse_release
|
||||
|
||||
|
||||
def _parsed(media_type: str = "movie"):
|
||||
"""Build a ParsedRelease with the requested media_type via the real parser."""
|
||||
if media_type == "tv_show":
|
||||
return parse_release("Show.S01E01.1080p-GRP")
|
||||
if media_type == "movie":
|
||||
return parse_release("Movie.2020.1080p-GRP")
|
||||
# "unknown" / other — feed a name the parser can't classify
|
||||
return parse_release("randomthing")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Single-file source #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestFile:
|
||||
def test_video_file_preserves_parsed_type(self, tmp_path: Path):
|
||||
f = tmp_path / "x.mkv"
|
||||
f.write_bytes(b"")
|
||||
assert detect_media_type(_parsed("movie"), f) == "movie"
|
||||
|
||||
def test_video_file_preserves_tv_type(self, tmp_path: Path):
|
||||
f = tmp_path / "ep.mp4"
|
||||
f.write_bytes(b"")
|
||||
assert detect_media_type(_parsed("tv_show"), f) == "tv_show"
|
||||
|
||||
def test_non_video_file_returns_other(self, tmp_path: Path):
|
||||
f = tmp_path / "x.iso"
|
||||
f.write_bytes(b"")
|
||||
assert detect_media_type(_parsed("movie"), f) == "other"
|
||||
|
||||
@pytest.mark.parametrize("ext", [".rar", ".zip", ".7z", ".exe", ".dmg"])
|
||||
def test_various_non_video_extensions(self, tmp_path: Path, ext):
|
||||
f = tmp_path / f"x{ext}"
|
||||
f.write_bytes(b"")
|
||||
assert detect_media_type(_parsed("movie"), f) == "other"
|
||||
|
||||
def test_metadata_only_file_keeps_parsed_type(self, tmp_path: Path):
|
||||
# Metadata extension is stripped from conclusive set — no video, no
|
||||
# non-video → falls through to parsed.media_type.
|
||||
f = tmp_path / "x.nfo"
|
||||
f.write_bytes(b"")
|
||||
assert detect_media_type(_parsed("movie"), f) == "movie"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Folder source #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestFolder:
|
||||
def test_folder_with_video_keeps_parsed_type(self, tmp_path: Path):
|
||||
(tmp_path / "main.mkv").write_bytes(b"")
|
||||
assert detect_media_type(_parsed("movie"), tmp_path) == "movie"
|
||||
|
||||
def test_folder_only_non_video_returns_other(self, tmp_path: Path):
|
||||
(tmp_path / "disc.iso").write_bytes(b"")
|
||||
(tmp_path / "part.rar").write_bytes(b"")
|
||||
assert detect_media_type(_parsed("movie"), tmp_path) == "other"
|
||||
|
||||
def test_folder_mixed_returns_unknown(self, tmp_path: Path):
|
||||
(tmp_path / "main.mkv").write_bytes(b"")
|
||||
(tmp_path / "extras.iso").write_bytes(b"")
|
||||
assert detect_media_type(_parsed("movie"), tmp_path) == "unknown"
|
||||
|
||||
def test_empty_folder_keeps_parsed_type(self, tmp_path: Path):
|
||||
assert detect_media_type(_parsed("tv_show"), tmp_path) == "tv_show"
|
||||
|
||||
def test_folder_only_metadata_keeps_parsed_type(self, tmp_path: Path):
|
||||
(tmp_path / "info.nfo").write_bytes(b"")
|
||||
(tmp_path / "cover.jpg").write_bytes(b"")
|
||||
(tmp_path / "subs.srt").write_bytes(b"")
|
||||
# All metadata → conclusive set empty → falls through.
|
||||
assert detect_media_type(_parsed("movie"), tmp_path) == "movie"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Metadata-noise resilience #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestMetadataIgnored:
|
||||
def test_video_plus_metadata_still_video(self, tmp_path: Path):
|
||||
(tmp_path / "main.mkv").write_bytes(b"")
|
||||
(tmp_path / "info.nfo").write_bytes(b"")
|
||||
(tmp_path / "cover.jpg").write_bytes(b"")
|
||||
(tmp_path / "subs.srt").write_bytes(b"")
|
||||
assert detect_media_type(_parsed("movie"), tmp_path) == "movie"
|
||||
|
||||
def test_non_video_plus_metadata_still_other(self, tmp_path: Path):
|
||||
(tmp_path / "disc.iso").write_bytes(b"")
|
||||
(tmp_path / "info.nfo").write_bytes(b"")
|
||||
assert detect_media_type(_parsed("movie"), tmp_path) == "other"
|
||||
|
||||
def test_case_insensitive_extensions(self, tmp_path: Path):
|
||||
# Suffix is lowercased before classification.
|
||||
f = tmp_path / "X.MKV"
|
||||
f.write_bytes(b"")
|
||||
assert detect_media_type(_parsed("movie"), f) == "movie"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Missing / non-existent paths #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestMissing:
|
||||
def test_nonexistent_path_keeps_parsed_type(self, tmp_path: Path):
|
||||
missing = tmp_path / "does_not_exist.mkv"
|
||||
# Doesn't exist → empty extension set → falls through.
|
||||
assert detect_media_type(_parsed("movie"), missing) == "movie"
|
||||
|
||||
def test_nonexistent_folder_keeps_parsed_type(self, tmp_path: Path):
|
||||
missing = tmp_path / "ghost"
|
||||
assert detect_media_type(_parsed("tv_show"), missing) == "tv_show"
|
||||
|
||||
def test_subfolder_not_recursed(self, tmp_path: Path):
|
||||
# _collect_extensions scans only the first level — files inside
|
||||
# subfolders must not influence the decision.
|
||||
sub = tmp_path / "sub"
|
||||
sub.mkdir()
|
||||
(sub / "deep.mkv").write_bytes(b"")
|
||||
# Top level has no files at all → empty → falls through to parsed type.
|
||||
assert detect_media_type(_parsed("movie"), tmp_path) == "movie"
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Tests for ``alfred.application.filesystem.enrich_from_probe``.
|
||||
|
||||
The function mutates a ``ParsedRelease`` in place using ffprobe ``MediaInfo``.
|
||||
Token-level values from the release name always win — only ``None`` fields
|
||||
are filled.
|
||||
|
||||
Coverage:
|
||||
|
||||
- ``TestQuality`` — resolution fill-in (and no-overwrite).
|
||||
- ``TestVideoCodec`` — codec map (hevc→x265, …) + uppercase fallback.
|
||||
- ``TestAudio`` — default track preferred over first; codec & channel maps
|
||||
with unknown-value fallbacks.
|
||||
- ``TestLanguages`` — append-only merge; ``und`` skipped; case-insensitive
|
||||
duplicate suppression.
|
||||
|
||||
Uses real ``ParsedRelease`` / ``MediaInfo`` instances — no mocking needed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alfred.application.filesystem.enrich_from_probe import enrich_from_probe
|
||||
from alfred.domain.release.value_objects import ParsedRelease
|
||||
from alfred.domain.shared.media import AudioTrack, MediaInfo, VideoTrack
|
||||
|
||||
|
||||
def _info_with_video(*, width=None, height=None, codec=None, **rest) -> MediaInfo:
|
||||
"""Helper: build a MediaInfo with a single video track (the common case)."""
|
||||
return MediaInfo(
|
||||
video_tracks=[VideoTrack(index=0, codec=codec, width=width, height=height)],
|
||||
**rest,
|
||||
)
|
||||
|
||||
|
||||
def _bare(**overrides) -> ParsedRelease:
|
||||
"""Build a minimal ParsedRelease with all enrichable fields = None."""
|
||||
defaults = dict(
|
||||
raw="X",
|
||||
normalised="X",
|
||||
title="X",
|
||||
year=None,
|
||||
season=None,
|
||||
episode=None,
|
||||
episode_end=None,
|
||||
quality=None,
|
||||
source=None,
|
||||
codec=None,
|
||||
group="UNKNOWN",
|
||||
tech_string="",
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return ParsedRelease(**defaults)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Quality / resolution #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestQuality:
|
||||
def test_fills_when_none(self):
|
||||
p = _bare()
|
||||
enrich_from_probe(p, _info_with_video(width=1920, height=1080))
|
||||
assert p.quality == "1080p"
|
||||
|
||||
def test_does_not_overwrite_existing(self):
|
||||
p = _bare(quality="2160p")
|
||||
enrich_from_probe(p, _info_with_video(width=1920, height=1080))
|
||||
assert p.quality == "2160p"
|
||||
|
||||
def test_no_dims_leaves_none(self):
|
||||
p = _bare()
|
||||
enrich_from_probe(p, MediaInfo())
|
||||
assert p.quality is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Video codec #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestVideoCodec:
|
||||
def test_hevc_to_x265(self):
|
||||
p = _bare()
|
||||
enrich_from_probe(p, _info_with_video(codec="hevc"))
|
||||
assert p.codec == "x265"
|
||||
|
||||
def test_h264_to_x264(self):
|
||||
p = _bare()
|
||||
enrich_from_probe(p, _info_with_video(codec="h264"))
|
||||
assert p.codec == "x264"
|
||||
|
||||
def test_unknown_codec_uppercased(self):
|
||||
p = _bare()
|
||||
enrich_from_probe(p, _info_with_video(codec="weird"))
|
||||
assert p.codec == "WEIRD"
|
||||
|
||||
def test_does_not_overwrite_existing(self):
|
||||
p = _bare(codec="HEVC")
|
||||
enrich_from_probe(p, _info_with_video(codec="h264"))
|
||||
assert p.codec == "HEVC"
|
||||
|
||||
def test_no_codec_leaves_none(self):
|
||||
p = _bare()
|
||||
enrich_from_probe(p, MediaInfo())
|
||||
assert p.codec is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Audio #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestAudio:
|
||||
def test_uses_default_track(self):
|
||||
info = MediaInfo(
|
||||
audio_tracks=[
|
||||
AudioTrack(0, "aac", 2, "stereo", "eng", is_default=False),
|
||||
AudioTrack(1, "eac3", 6, "5.1", "eng", is_default=True),
|
||||
]
|
||||
)
|
||||
p = _bare()
|
||||
enrich_from_probe(p, info)
|
||||
assert p.audio_codec == "EAC3"
|
||||
assert p.audio_channels == "5.1"
|
||||
|
||||
def test_falls_back_to_first_track_when_no_default(self):
|
||||
info = MediaInfo(
|
||||
audio_tracks=[
|
||||
AudioTrack(0, "ac3", 6, "5.1", "eng"),
|
||||
AudioTrack(1, "aac", 2, "stereo", "fre"),
|
||||
]
|
||||
)
|
||||
p = _bare()
|
||||
enrich_from_probe(p, info)
|
||||
assert p.audio_codec == "AC3"
|
||||
assert p.audio_channels == "5.1"
|
||||
|
||||
def test_channel_count_unknown_falls_back(self):
|
||||
info = MediaInfo(
|
||||
audio_tracks=[AudioTrack(0, "aac", 4, "quad", "eng")]
|
||||
)
|
||||
p = _bare()
|
||||
enrich_from_probe(p, info)
|
||||
assert p.audio_channels == "4ch"
|
||||
|
||||
def test_unknown_audio_codec_uppercased(self):
|
||||
info = MediaInfo(
|
||||
audio_tracks=[AudioTrack(0, "newcodec", 2, "stereo", "eng")]
|
||||
)
|
||||
p = _bare()
|
||||
enrich_from_probe(p, info)
|
||||
assert p.audio_codec == "NEWCODEC"
|
||||
|
||||
def test_no_audio_tracks(self):
|
||||
p = _bare()
|
||||
enrich_from_probe(p, MediaInfo())
|
||||
assert p.audio_codec is None
|
||||
assert p.audio_channels is None
|
||||
|
||||
def test_does_not_overwrite_existing_audio_fields(self):
|
||||
info = MediaInfo(
|
||||
audio_tracks=[AudioTrack(0, "ac3", 6, "5.1", "eng")]
|
||||
)
|
||||
p = _bare(audio_codec="DTS-HD.MA", audio_channels="7.1")
|
||||
enrich_from_probe(p, info)
|
||||
assert p.audio_codec == "DTS-HD.MA"
|
||||
assert p.audio_channels == "7.1"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Languages #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestLanguages:
|
||||
def test_appends_new(self):
|
||||
info = MediaInfo(
|
||||
audio_tracks=[
|
||||
AudioTrack(0, "aac", 2, "stereo", "eng"),
|
||||
AudioTrack(1, "aac", 2, "stereo", "fre"),
|
||||
]
|
||||
)
|
||||
p = _bare()
|
||||
enrich_from_probe(p, info)
|
||||
assert p.languages == ["eng", "fre"]
|
||||
|
||||
def test_skips_und(self):
|
||||
info = MediaInfo(
|
||||
audio_tracks=[
|
||||
AudioTrack(0, "aac", 2, "stereo", "und"),
|
||||
AudioTrack(1, "aac", 2, "stereo", "eng"),
|
||||
]
|
||||
)
|
||||
p = _bare()
|
||||
enrich_from_probe(p, info)
|
||||
assert p.languages == ["eng"]
|
||||
|
||||
def test_dedup_against_existing_case_insensitive(self):
|
||||
# existing token-level languages are typically upper-case ("FRENCH", "ENG")
|
||||
# The current logic compares track.lang.upper() against existing —
|
||||
# so a track with "eng" is suppressed if "ENG" is already in languages.
|
||||
info = MediaInfo(
|
||||
audio_tracks=[
|
||||
AudioTrack(0, "aac", 2, "stereo", "eng"),
|
||||
AudioTrack(1, "aac", 2, "stereo", "fre"),
|
||||
]
|
||||
)
|
||||
p = _bare()
|
||||
p.languages = ["ENG"]
|
||||
enrich_from_probe(p, info)
|
||||
# "eng" → upper "ENG" already present → skipped. "fre" → "FRE" new → kept.
|
||||
assert p.languages == ["ENG", "fre"]
|
||||
|
||||
def test_no_audio_tracks_leaves_languages_empty(self):
|
||||
p = _bare()
|
||||
enrich_from_probe(p, MediaInfo())
|
||||
assert p.languages == []
|
||||
@@ -0,0 +1,563 @@
|
||||
"""Tests for ``alfred.application.filesystem.manage_subtitles``.
|
||||
|
||||
``ManageSubtitlesUseCase`` orchestrates the subtitle pipeline:
|
||||
KB load → pattern resolution → identify → match → place → persist.
|
||||
|
||||
Strategy: mock the heavy collaborators (``SubtitleIdentifier``,
|
||||
``PatternDetector``, ``SubtitleMatcher``, ``SubtitlePlacer``,
|
||||
``RuleSetRepository``, ``SubtitleMetadataStore``, ``SubtitleKnowledgeBase``)
|
||||
at the use-case module path. The use case instantiates them inline so each
|
||||
patch targets a single class symbol.
|
||||
|
||||
Coverage:
|
||||
|
||||
- ``TestSourceMissing`` — source_not_found short-circuit when neither file
|
||||
nor parent dir exists.
|
||||
- ``TestPatternResolution`` — confirmed_pattern_id wins; falls back to
|
||||
stored confirmed pattern; falls back to detector; falls back to
|
||||
"adjacent"; pattern_not_found error when KB has nothing.
|
||||
- ``TestNoTracks`` — empty identifier output → status=ok, empty placed list.
|
||||
- ``TestEmbeddedShortCircuit`` — EMBEDDED scan_strategy yields ``available``
|
||||
list and never calls the matcher/placer.
|
||||
- ``TestMatcherFlow`` — unresolved → needs_clarification; no matches → ok
|
||||
with skipped_count; happy path runs placer + appends history.
|
||||
- ``TestDryRun`` — dry_run skips placement, returns predicted destinations.
|
||||
- ``TestHelpers`` — ``_infer_library_root``, ``_to_imdb_id``,
|
||||
``_to_unresolved_dto``, ``_pair_placed_with_tracks``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from alfred.application.filesystem.manage_subtitles import (
|
||||
ManageSubtitlesUseCase,
|
||||
_infer_library_root,
|
||||
_pair_placed_with_tracks,
|
||||
_to_imdb_id,
|
||||
_to_unresolved_dto,
|
||||
)
|
||||
from alfred.domain.subtitles.entities import MediaSubtitleMetadata, SubtitleCandidate
|
||||
from alfred.domain.subtitles.services.placer import PlacedTrack, PlaceResult
|
||||
from alfred.domain.subtitles.value_objects import (
|
||||
ScanStrategy,
|
||||
SubtitleFormat,
|
||||
SubtitleLanguage,
|
||||
SubtitleType,
|
||||
)
|
||||
|
||||
SRT = SubtitleFormat(id="srt", extensions=[".srt"])
|
||||
FRA = SubtitleLanguage(code="fra", tokens=["fr"])
|
||||
ENG = SubtitleLanguage(code="eng", tokens=["en"])
|
||||
|
||||
|
||||
def _track(
|
||||
*,
|
||||
lang=FRA,
|
||||
fmt=SRT,
|
||||
stype=SubtitleType.STANDARD,
|
||||
file_path: Path | None = None,
|
||||
is_embedded: bool = False,
|
||||
raw_tokens: list[str] | None = None,
|
||||
file_size_kb: float | None = None,
|
||||
) -> SubtitleCandidate:
|
||||
return SubtitleCandidate(
|
||||
language=lang,
|
||||
format=fmt,
|
||||
subtitle_type=stype,
|
||||
file_path=file_path,
|
||||
is_embedded=is_embedded,
|
||||
raw_tokens=raw_tokens or [],
|
||||
file_size_kb=file_size_kb,
|
||||
)
|
||||
|
||||
|
||||
def _pattern(
|
||||
pid: str = "adjacent", strategy: ScanStrategy = ScanStrategy.ADJACENT
|
||||
) -> MagicMock:
|
||||
p = MagicMock()
|
||||
p.id = pid
|
||||
p.scan_strategy = strategy
|
||||
return p
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Helper functions #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestHelpers:
|
||||
def test_infer_library_root_tv_show(self):
|
||||
# video → Season 01 → Show
|
||||
video = Path("/lib/tv/Show/Season.01/E01.mkv")
|
||||
assert _infer_library_root(video, "tv_show") == Path("/lib/tv/Show")
|
||||
|
||||
def test_infer_library_root_movie(self):
|
||||
video = Path("/lib/movies/Movie.2010/Movie.2010.mkv")
|
||||
assert _infer_library_root(video, "movie") == Path("/lib/movies/Movie.2010")
|
||||
|
||||
def test_to_imdb_id_none_or_empty(self):
|
||||
assert _to_imdb_id(None) is None
|
||||
assert _to_imdb_id("") is None
|
||||
|
||||
def test_to_imdb_id_valid(self):
|
||||
out = _to_imdb_id("tt1375666")
|
||||
assert out is not None
|
||||
assert str(out) == "tt1375666"
|
||||
|
||||
def test_to_imdb_id_invalid_returns_none(self):
|
||||
assert _to_imdb_id("not-an-imdb-id") is None
|
||||
|
||||
def test_to_unresolved_dto_unknown_language(self):
|
||||
t = _track(lang=None, raw_tokens=["fr", "x"], file_size_kb=12.0)
|
||||
t.file_path = Path("/x/a.srt")
|
||||
out = _to_unresolved_dto(t)
|
||||
assert out.reason == "unknown_language"
|
||||
assert out.raw_tokens == ["fr", "x"]
|
||||
assert out.file_path == "/x/a.srt"
|
||||
assert out.file_size_kb == 12.0
|
||||
|
||||
def test_to_unresolved_dto_low_confidence(self):
|
||||
t = _track(lang=FRA, raw_tokens=["fr"])
|
||||
out = _to_unresolved_dto(t)
|
||||
assert out.reason == "low_confidence"
|
||||
|
||||
def test_to_unresolved_dto_no_file_path(self):
|
||||
t = _track(lang=None)
|
||||
out = _to_unresolved_dto(t)
|
||||
assert out.file_path is None
|
||||
|
||||
def test_pair_placed_with_tracks_by_path(self):
|
||||
src1, src2 = Path("/in/a.srt"), Path("/in/b.srt")
|
||||
t1 = _track(file_path=src1, lang=FRA)
|
||||
t2 = _track(file_path=src2, lang=ENG)
|
||||
p1 = PlacedTrack(source=src1, destination=Path("/out/a"), filename="a")
|
||||
p2 = PlacedTrack(source=src2, destination=Path("/out/b"), filename="b")
|
||||
pairs = _pair_placed_with_tracks([p1, p2], [t1, t2])
|
||||
assert pairs == [(p1, t1), (p2, t2)]
|
||||
|
||||
def test_pair_placed_falls_back_to_positional(self):
|
||||
# Placed source path doesn't match any track.file_path → fallback uses tracks[0].
|
||||
t = _track(file_path=Path("/in/known.srt"))
|
||||
p = PlacedTrack(source=Path("/in/ghost.srt"), destination=Path("/x"), filename="x")
|
||||
pairs = _pair_placed_with_tracks([p], [t])
|
||||
assert pairs == [(p, t)]
|
||||
|
||||
def test_pair_placed_empty_inputs(self):
|
||||
assert _pair_placed_with_tracks([], []) == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Use case shared fixtures #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
MOD = "alfred.application.filesystem.manage_subtitles"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def video(tmp_path):
|
||||
"""Real source + destination video paths inside tmp_path."""
|
||||
src_dir = tmp_path / "dl"
|
||||
src_dir.mkdir()
|
||||
src = src_dir / "Movie.2010.mkv"
|
||||
src.write_bytes(b"")
|
||||
dest_dir = tmp_path / "lib" / "Movie.2010"
|
||||
dest_dir.mkdir(parents=True)
|
||||
dest = dest_dir / "Movie.2010.mkv"
|
||||
dest.write_bytes(b"")
|
||||
return src, dest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patches():
|
||||
"""Patch all collaborator classes the use case instantiates inline."""
|
||||
with (
|
||||
patch(f"{MOD}.KnowledgeLoader") as mock_loader,
|
||||
patch(f"{MOD}.SubtitleKnowledgeBase") as mock_kb_cls,
|
||||
patch(f"{MOD}.SubtitleMetadataStore") as mock_store_cls,
|
||||
patch(f"{MOD}.RuleSetRepository") as mock_repo_cls,
|
||||
patch(f"{MOD}.SubtitleIdentifier") as mock_id_cls,
|
||||
patch(f"{MOD}.PatternDetector") as mock_det_cls,
|
||||
patch(f"{MOD}.SubtitleMatcher") as mock_match_cls,
|
||||
patch(f"{MOD}.SubtitlePlacer") as mock_place_cls,
|
||||
patch(f"{MOD}.get_memory") as mock_get_memory,
|
||||
):
|
||||
# KB returns a default "adjacent" pattern by default.
|
||||
kb = mock_kb_cls.return_value
|
||||
kb.pattern.return_value = _pattern()
|
||||
|
||||
# Store starts empty.
|
||||
store = mock_store_cls.return_value
|
||||
store.confirmed_pattern.return_value = None
|
||||
|
||||
# Detector returns no detection by default.
|
||||
det = mock_det_cls.return_value
|
||||
det.detect.return_value = {"detected": None, "confidence": 0.0}
|
||||
|
||||
# Identifier: 0 tracks by default.
|
||||
ident = mock_id_cls.return_value
|
||||
ident.identify.return_value = MediaSubtitleMetadata(
|
||||
media_id=None,
|
||||
media_type="movie",
|
||||
release_group=None,
|
||||
detected_pattern_id="adjacent",
|
||||
)
|
||||
|
||||
# Matcher: no matched, no unresolved by default.
|
||||
matcher = mock_match_cls.return_value
|
||||
matcher.match.return_value = ([], [])
|
||||
|
||||
# Placer: empty result.
|
||||
placer = mock_place_cls.return_value
|
||||
placer.place.return_value = PlaceResult(placed=[], skipped=[])
|
||||
|
||||
# Rules: simple object passthrough; the use case only forwards it.
|
||||
repo = mock_repo_cls.return_value
|
||||
repo.load.return_value.resolve.return_value = MagicMock(name="Rules")
|
||||
|
||||
# get_memory: works by default.
|
||||
mock_get_memory.return_value.ltm.subtitle_preferences = MagicMock()
|
||||
|
||||
yield {
|
||||
"kb": kb,
|
||||
"store": store,
|
||||
"repo": repo,
|
||||
"ident": ident,
|
||||
"det": det,
|
||||
"matcher": matcher,
|
||||
"placer": placer,
|
||||
"loader": mock_loader,
|
||||
"get_memory": mock_get_memory,
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Source missing #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestSourceMissing:
|
||||
def test_source_and_parent_missing_returns_error(self, tmp_path):
|
||||
# Neither path nor parent exists.
|
||||
uc = ManageSubtitlesUseCase()
|
||||
out = uc.execute(
|
||||
source_video=str(tmp_path / "ghost" / "ghost.mkv"),
|
||||
destination_video=str(tmp_path / "lib" / "x.mkv"),
|
||||
)
|
||||
assert out.status == "error"
|
||||
assert out.error == "source_not_found"
|
||||
|
||||
def test_source_missing_but_parent_exists_does_not_error_early(
|
||||
self, tmp_path, patches
|
||||
):
|
||||
# Parent dir exists → use case proceeds. With default mocks the
|
||||
# identifier returns 0 tracks → status="ok".
|
||||
(tmp_path / "dl").mkdir()
|
||||
(tmp_path / "lib").mkdir()
|
||||
out = ManageSubtitlesUseCase().execute(
|
||||
source_video=str(tmp_path / "dl" / "missing.mkv"),
|
||||
destination_video=str(tmp_path / "lib" / "missing.mkv"),
|
||||
media_type="movie",
|
||||
)
|
||||
assert out.status == "ok"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Pattern resolution #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestPatternResolution:
|
||||
def test_confirmed_pattern_id_wins(self, video, patches):
|
||||
src, dest = video
|
||||
custom = _pattern("subs_flat")
|
||||
patches["kb"].pattern.side_effect = lambda pid: (
|
||||
custom if pid == "subs_flat" else _pattern()
|
||||
)
|
||||
ManageSubtitlesUseCase().execute(
|
||||
source_video=str(src),
|
||||
destination_video=str(dest),
|
||||
media_type="movie",
|
||||
confirmed_pattern_id="subs_flat",
|
||||
)
|
||||
# Identifier called with the confirmed pattern (not the default).
|
||||
args, kwargs = patches["ident"].identify.call_args
|
||||
assert kwargs["pattern"].id == "subs_flat"
|
||||
# Detector should not even run when an explicit confirmation is given.
|
||||
patches["det"].detect.assert_not_called()
|
||||
|
||||
def test_confirmed_pattern_id_unknown_falls_through_to_stored(self, video, patches):
|
||||
src, dest = video
|
||||
# KB knows nothing about the requested override → returns None.
|
||||
# Stored value provides 'subs_flat'.
|
||||
patches["store"].confirmed_pattern.return_value = "subs_flat"
|
||||
flat = _pattern("subs_flat")
|
||||
patches["kb"].pattern.side_effect = lambda pid: {
|
||||
"subs_flat": flat,
|
||||
"adjacent": _pattern(),
|
||||
}.get(pid)
|
||||
ManageSubtitlesUseCase().execute(
|
||||
source_video=str(src),
|
||||
destination_video=str(dest),
|
||||
media_type="movie",
|
||||
confirmed_pattern_id="DOES_NOT_EXIST",
|
||||
)
|
||||
assert patches["ident"].identify.call_args.kwargs["pattern"].id == "subs_flat"
|
||||
|
||||
def test_detector_used_when_no_confirmed_and_no_stored(self, video, patches):
|
||||
src, dest = video
|
||||
detected = _pattern("episode_subfolder")
|
||||
patches["det"].detect.return_value = {
|
||||
"detected": detected,
|
||||
"confidence": 0.9,
|
||||
}
|
||||
ManageSubtitlesUseCase().execute(
|
||||
source_video=str(src),
|
||||
destination_video=str(dest),
|
||||
media_type="movie",
|
||||
)
|
||||
assert (
|
||||
patches["ident"].identify.call_args.kwargs["pattern"].id
|
||||
== "episode_subfolder"
|
||||
)
|
||||
|
||||
def test_detector_low_confidence_falls_back_to_adjacent(self, video, patches):
|
||||
src, dest = video
|
||||
patches["det"].detect.return_value = {
|
||||
"detected": _pattern("episode_subfolder"),
|
||||
"confidence": 0.1,
|
||||
}
|
||||
ManageSubtitlesUseCase().execute(
|
||||
source_video=str(src),
|
||||
destination_video=str(dest),
|
||||
media_type="movie",
|
||||
)
|
||||
# Falls back via kb.pattern('adjacent')
|
||||
assert patches["kb"].pattern.call_args_list[-1].args == ("adjacent",)
|
||||
|
||||
def test_pattern_not_found_when_kb_returns_none(self, video, patches):
|
||||
src, dest = video
|
||||
patches["kb"].pattern.return_value = None # nothing known
|
||||
patches["det"].detect.return_value = {"detected": None, "confidence": 0.0}
|
||||
out = ManageSubtitlesUseCase().execute(
|
||||
source_video=str(src),
|
||||
destination_video=str(dest),
|
||||
media_type="movie",
|
||||
)
|
||||
assert out.status == "error"
|
||||
assert out.error == "pattern_not_found"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# No tracks #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestNoTracks:
|
||||
def test_zero_tracks_returns_ok_empty(self, video, patches):
|
||||
src, dest = video
|
||||
out = ManageSubtitlesUseCase().execute(
|
||||
source_video=str(src),
|
||||
destination_video=str(dest),
|
||||
media_type="movie",
|
||||
)
|
||||
assert out.status == "ok"
|
||||
assert out.placed == []
|
||||
assert out.skipped_count == 0
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Embedded short-circuit #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestEmbeddedShortCircuit:
|
||||
def test_embedded_returns_available_and_skips_matcher(self, video, patches):
|
||||
src, dest = video
|
||||
patches["kb"].pattern.return_value = _pattern("embedded", ScanStrategy.EMBEDDED)
|
||||
patches["ident"].identify.return_value = MediaSubtitleMetadata(
|
||||
media_id=None,
|
||||
media_type="movie",
|
||||
release_group=None,
|
||||
detected_pattern_id="embedded",
|
||||
embedded_tracks=[
|
||||
_track(lang=FRA, is_embedded=True),
|
||||
_track(lang=ENG, stype=SubtitleType.SDH, is_embedded=True),
|
||||
],
|
||||
)
|
||||
out = ManageSubtitlesUseCase().execute(
|
||||
source_video=str(src),
|
||||
destination_video=str(dest),
|
||||
media_type="movie",
|
||||
)
|
||||
assert out.status == "ok"
|
||||
assert out.placed == []
|
||||
assert out.available is not None
|
||||
langs = {a.language for a in out.available}
|
||||
assert {"fra", "eng"}.issubset(langs)
|
||||
patches["matcher"].match.assert_not_called()
|
||||
patches["placer"].place.assert_not_called()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Matcher flow #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestMatcherFlow:
|
||||
def test_unresolved_returns_needs_clarification(self, video, patches):
|
||||
src, dest = video
|
||||
ext = [_track(file_path=src.parent / "a.srt")]
|
||||
patches["ident"].identify.return_value = MediaSubtitleMetadata(
|
||||
media_id=None,
|
||||
media_type="movie",
|
||||
release_group=None,
|
||||
detected_pattern_id="adjacent",
|
||||
external_tracks=ext,
|
||||
)
|
||||
unresolved_track = _track(
|
||||
lang=None, raw_tokens=["xx"], file_path=src.parent / "?.srt"
|
||||
)
|
||||
patches["matcher"].match.return_value = ([], [unresolved_track])
|
||||
out = ManageSubtitlesUseCase().execute(
|
||||
source_video=str(src),
|
||||
destination_video=str(dest),
|
||||
media_type="movie",
|
||||
)
|
||||
assert out.status == "needs_clarification"
|
||||
assert out.unresolved and out.unresolved[0].reason == "unknown_language"
|
||||
patches["placer"].place.assert_not_called()
|
||||
|
||||
def test_no_matches_returns_ok_with_skipped(self, video, patches):
|
||||
src, dest = video
|
||||
patches["ident"].identify.return_value = MediaSubtitleMetadata(
|
||||
media_id=None,
|
||||
media_type="movie",
|
||||
release_group=None,
|
||||
detected_pattern_id="adjacent",
|
||||
external_tracks=[_track(file_path=src.parent / "a.srt")],
|
||||
embedded_tracks=[_track(is_embedded=True)],
|
||||
)
|
||||
patches["matcher"].match.return_value = ([], []) # no matches, no unresolved
|
||||
out = ManageSubtitlesUseCase().execute(
|
||||
source_video=str(src),
|
||||
destination_video=str(dest),
|
||||
media_type="movie",
|
||||
)
|
||||
assert out.status == "ok"
|
||||
assert out.placed == []
|
||||
# total_count = 1 ext + 1 emb = 2
|
||||
assert out.skipped_count == 2
|
||||
|
||||
def test_happy_path_places_and_persists(self, video, patches):
|
||||
src, dest = video
|
||||
src_sub = src.parent / "a.srt"
|
||||
src_sub.write_text("")
|
||||
matched = [_track(file_path=src_sub, lang=FRA)]
|
||||
patches["ident"].identify.return_value = MediaSubtitleMetadata(
|
||||
media_id=None,
|
||||
media_type="movie",
|
||||
release_group=None,
|
||||
detected_pattern_id="adjacent",
|
||||
external_tracks=matched,
|
||||
)
|
||||
patches["matcher"].match.return_value = (matched, [])
|
||||
placed = PlacedTrack(
|
||||
source=src_sub,
|
||||
destination=dest.parent / "Movie.2010.fra.srt",
|
||||
filename="Movie.2010.fra.srt",
|
||||
)
|
||||
patches["placer"].place.return_value = PlaceResult(placed=[placed], skipped=[])
|
||||
|
||||
out = ManageSubtitlesUseCase().execute(
|
||||
source_video=str(src),
|
||||
destination_video=str(dest),
|
||||
media_type="movie",
|
||||
release_group="KONTRAST",
|
||||
season=1,
|
||||
episode=2,
|
||||
)
|
||||
assert out.status == "ok"
|
||||
assert len(out.placed) == 1
|
||||
assert out.placed[0].filename == "Movie.2010.fra.srt"
|
||||
# History was appended with season/episode/group.
|
||||
patches["store"].append_history.assert_called_once()
|
||||
args, _ = patches["store"].append_history.call_args
|
||||
# signature: append_history(pairs, season, episode, release_group)
|
||||
assert args[1] == 1
|
||||
assert args[2] == 2
|
||||
assert args[3] == "KONTRAST"
|
||||
|
||||
def test_get_memory_failure_falls_through_to_rules_repo(self, video, patches):
|
||||
# The use case swallows get_memory() exceptions and continues with
|
||||
# subtitle_prefs=None. We assert: still progresses past matcher.
|
||||
src, dest = video
|
||||
patches["get_memory"].side_effect = RuntimeError("not initialised")
|
||||
patches["ident"].identify.return_value = MediaSubtitleMetadata(
|
||||
media_id=None,
|
||||
media_type="movie",
|
||||
release_group=None,
|
||||
detected_pattern_id="adjacent",
|
||||
external_tracks=[_track(file_path=src.parent / "a.srt")],
|
||||
)
|
||||
patches["matcher"].match.return_value = ([], [])
|
||||
out = ManageSubtitlesUseCase().execute(
|
||||
source_video=str(src),
|
||||
destination_video=str(dest),
|
||||
media_type="movie",
|
||||
)
|
||||
assert out.status == "ok"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Dry run #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestDryRun:
|
||||
def test_dry_run_skips_placer_and_returns_predicted(self, video, patches):
|
||||
src, dest = video
|
||||
src_sub = src.parent / "a.srt"
|
||||
src_sub.write_text("")
|
||||
matched = [_track(file_path=src_sub, lang=FRA)]
|
||||
patches["ident"].identify.return_value = MediaSubtitleMetadata(
|
||||
media_id=None,
|
||||
media_type="movie",
|
||||
release_group=None,
|
||||
detected_pattern_id="adjacent",
|
||||
external_tracks=matched,
|
||||
)
|
||||
patches["matcher"].match.return_value = (matched, [])
|
||||
out = ManageSubtitlesUseCase().execute(
|
||||
source_video=str(src),
|
||||
destination_video=str(dest),
|
||||
media_type="movie",
|
||||
dry_run=True,
|
||||
)
|
||||
assert out.status == "ok"
|
||||
assert out.placed and out.placed[0].filename.endswith(".fra.srt")
|
||||
patches["placer"].place.assert_not_called()
|
||||
patches["store"].append_history.assert_not_called()
|
||||
|
||||
def test_dry_run_skips_tracks_without_file_path(self, video, patches):
|
||||
src, dest = video
|
||||
matched = [_track(file_path=None, lang=FRA)] # no file_path → skipped
|
||||
patches["ident"].identify.return_value = MediaSubtitleMetadata(
|
||||
media_id=None,
|
||||
media_type="movie",
|
||||
release_group=None,
|
||||
detected_pattern_id="adjacent",
|
||||
external_tracks=matched,
|
||||
)
|
||||
patches["matcher"].match.return_value = (matched, [])
|
||||
out = ManageSubtitlesUseCase().execute(
|
||||
source_video=str(src),
|
||||
destination_video=str(dest),
|
||||
media_type="movie",
|
||||
dry_run=True,
|
||||
)
|
||||
assert out.placed == []
|
||||
@@ -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"] == []
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Tests for ``alfred.application.movies.search_movie.SearchMovieUseCase``.
|
||||
|
||||
The use case wraps ``TMDBClient.search_media`` and converts results / errors
|
||||
into a ``SearchMovieResponse`` envelope (status="ok"|"error").
|
||||
|
||||
Coverage:
|
||||
|
||||
- ``TestSuccess`` — full MediaResult with imdb_id → ok+imdb_id; missing
|
||||
imdb_id → ok+no_imdb_id; TV media_type preserved.
|
||||
- ``TestErrorTranslation`` — ``TMDBNotFoundError`` → not_found,
|
||||
``TMDBConfigurationError`` → configuration_error,
|
||||
``TMDBAPIError`` → api_error, ``ValueError`` → validation_failed.
|
||||
- ``TestPassThrough`` — query is forwarded to the client unchanged.
|
||||
|
||||
TMDBClient is fully mocked — no real HTTP.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from alfred.application.movies.search_movie import SearchMovieUseCase
|
||||
from alfred.infrastructure.api.tmdb.dto import MediaResult
|
||||
from alfred.infrastructure.api.tmdb.exceptions import (
|
||||
TMDBAPIError,
|
||||
TMDBConfigurationError,
|
||||
TMDBNotFoundError,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def use_case(client):
|
||||
return SearchMovieUseCase(client)
|
||||
|
||||
|
||||
def _result(**kw) -> MediaResult:
|
||||
defaults = dict(
|
||||
tmdb_id=1,
|
||||
title="Inception",
|
||||
media_type="movie",
|
||||
imdb_id="tt1375666",
|
||||
overview="o",
|
||||
release_date="2010-07-15",
|
||||
poster_path="/x.jpg",
|
||||
vote_average=8.4,
|
||||
)
|
||||
defaults.update(kw)
|
||||
return MediaResult(**defaults)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Success paths #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestSuccess:
|
||||
def test_full_result_returns_ok_with_imdb_id(self, client, use_case):
|
||||
client.search_media.return_value = _result()
|
||||
r = use_case.execute("Inception")
|
||||
assert r.status == "ok"
|
||||
assert r.imdb_id == "tt1375666"
|
||||
assert r.title == "Inception"
|
||||
assert r.media_type == "movie"
|
||||
assert r.tmdb_id == 1
|
||||
assert r.vote_average == 8.4
|
||||
assert r.error is None
|
||||
|
||||
def test_tv_result(self, client, use_case):
|
||||
client.search_media.return_value = _result(
|
||||
media_type="tv", title="Breaking Bad", imdb_id="tt0903747"
|
||||
)
|
||||
r = use_case.execute("Breaking Bad")
|
||||
assert r.status == "ok"
|
||||
assert r.media_type == "tv"
|
||||
assert r.imdb_id == "tt0903747"
|
||||
|
||||
def test_missing_imdb_id_returns_ok_with_no_imdb_id_error(self, client, use_case):
|
||||
client.search_media.return_value = _result(imdb_id=None)
|
||||
r = use_case.execute("Inception")
|
||||
assert r.status == "ok"
|
||||
assert r.error == "no_imdb_id"
|
||||
assert r.message is not None
|
||||
assert "Inception" in r.message
|
||||
assert r.imdb_id is None
|
||||
assert r.title == "Inception"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Error translation #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestErrorTranslation:
|
||||
def test_not_found(self, client, use_case):
|
||||
client.search_media.side_effect = TMDBNotFoundError("no match")
|
||||
r = use_case.execute("ghost")
|
||||
assert r.status == "error"
|
||||
assert r.error == "not_found"
|
||||
assert "no match" in r.message
|
||||
|
||||
def test_configuration_error(self, client, use_case):
|
||||
client.search_media.side_effect = TMDBConfigurationError("missing key")
|
||||
r = use_case.execute("x")
|
||||
assert r.status == "error"
|
||||
assert r.error == "configuration_error"
|
||||
|
||||
def test_api_error(self, client, use_case):
|
||||
client.search_media.side_effect = TMDBAPIError("500 oops")
|
||||
r = use_case.execute("x")
|
||||
assert r.status == "error"
|
||||
assert r.error == "api_error"
|
||||
assert "500" in r.message
|
||||
|
||||
def test_validation_error(self, client, use_case):
|
||||
client.search_media.side_effect = ValueError("query too long")
|
||||
r = use_case.execute("x")
|
||||
assert r.status == "error"
|
||||
assert r.error == "validation_failed"
|
||||
assert "too long" in r.message
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Pass-through #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestPassThrough:
|
||||
def test_query_forwarded_verbatim(self, client, use_case):
|
||||
client.search_media.return_value = _result()
|
||||
use_case.execute("Inception")
|
||||
client.search_media.assert_called_once_with("Inception")
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Tests for ``alfred.application.torrents.search_torrents.SearchTorrentsUseCase``.
|
||||
|
||||
Wraps ``KnabenClient.search`` and converts ``TorrentResult`` objects into
|
||||
plain dicts inside a ``SearchTorrentsResponse`` envelope.
|
||||
|
||||
Coverage:
|
||||
|
||||
- ``TestSuccess`` — multiple results → status="ok" + ``count`` + dict shape.
|
||||
- ``TestEmptyResults`` — empty list from client → status="error",
|
||||
error="not_found".
|
||||
- ``TestErrorTranslation`` — ``KnabenNotFoundError`` → not_found,
|
||||
``KnabenAPIError`` → api_error, ``ValueError`` → validation_failed.
|
||||
- ``TestPassThrough`` — query + limit are forwarded to the client.
|
||||
|
||||
KnabenClient is fully mocked — no real HTTP.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from alfred.application.torrents.search_torrents import SearchTorrentsUseCase
|
||||
from alfred.infrastructure.api.knaben.dto import TorrentResult
|
||||
from alfred.infrastructure.api.knaben.exceptions import (
|
||||
KnabenAPIError,
|
||||
KnabenNotFoundError,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def use_case(client):
|
||||
return SearchTorrentsUseCase(client)
|
||||
|
||||
|
||||
def _torrent(**kw) -> TorrentResult:
|
||||
defaults = dict(
|
||||
title="Inception.2010.1080p",
|
||||
size="10 GB",
|
||||
seeders=500,
|
||||
leechers=50,
|
||||
magnet="magnet:?xt=abc",
|
||||
info_hash="abc",
|
||||
tracker="rarbg",
|
||||
upload_date="2020-01-01",
|
||||
category="movie",
|
||||
)
|
||||
defaults.update(kw)
|
||||
return TorrentResult(**defaults)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Success #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestSuccess:
|
||||
def test_single_result_serialized_to_dict(self, client, use_case):
|
||||
client.search.return_value = [_torrent()]
|
||||
r = use_case.execute("Inception")
|
||||
assert r.status == "ok"
|
||||
assert r.count == 1
|
||||
assert len(r.torrents) == 1
|
||||
t = r.torrents[0]
|
||||
assert t["name"] == "Inception.2010.1080p"
|
||||
assert t["size"] == "10 GB"
|
||||
assert t["seeders"] == 500
|
||||
assert t["leechers"] == 50
|
||||
assert t["magnet"].startswith("magnet:")
|
||||
assert t["info_hash"] == "abc"
|
||||
assert t["tracker"] == "rarbg"
|
||||
assert t["upload_date"] == "2020-01-01"
|
||||
assert t["category"] == "movie"
|
||||
|
||||
def test_multiple_results(self, client, use_case):
|
||||
client.search.return_value = [
|
||||
_torrent(title="A"),
|
||||
_torrent(title="B"),
|
||||
_torrent(title="C"),
|
||||
]
|
||||
r = use_case.execute("x")
|
||||
assert r.count == 3
|
||||
assert [t["name"] for t in r.torrents] == ["A", "B", "C"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Empty #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestEmptyResults:
|
||||
def test_empty_list_becomes_not_found(self, client, use_case):
|
||||
client.search.return_value = []
|
||||
r = use_case.execute("ghost")
|
||||
assert r.status == "error"
|
||||
assert r.error == "not_found"
|
||||
assert "ghost" in r.message
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Error translation #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestErrorTranslation:
|
||||
def test_not_found(self, client, use_case):
|
||||
client.search.side_effect = KnabenNotFoundError("nope")
|
||||
r = use_case.execute("x")
|
||||
assert r.status == "error"
|
||||
assert r.error == "not_found"
|
||||
assert "nope" in r.message
|
||||
|
||||
def test_api_error(self, client, use_case):
|
||||
client.search.side_effect = KnabenAPIError("rate limited")
|
||||
r = use_case.execute("x")
|
||||
assert r.status == "error"
|
||||
assert r.error == "api_error"
|
||||
assert "rate" in r.message
|
||||
|
||||
def test_validation_error(self, client, use_case):
|
||||
client.search.side_effect = ValueError("too long")
|
||||
r = use_case.execute("x")
|
||||
assert r.status == "error"
|
||||
assert r.error == "validation_failed"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Pass-through #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestPassThrough:
|
||||
def test_default_limit_forwarded(self, client, use_case):
|
||||
client.search.return_value = [_torrent()]
|
||||
use_case.execute("Inception")
|
||||
client.search.assert_called_once_with("Inception", limit=10)
|
||||
|
||||
def test_custom_limit_forwarded(self, client, use_case):
|
||||
client.search.return_value = [_torrent()]
|
||||
use_case.execute("Inception", limit=25)
|
||||
client.search.assert_called_once_with("Inception", limit=25)
|
||||
Reference in New Issue
Block a user