From 7da0f887e7c18a23c6e012ba2de9844ce19c06f9 Mon Sep 17 00:00:00 2001 From: Francwa Date: Mon, 25 May 2026 21:07:25 +0200 Subject: [PATCH] =?UTF-8?q?refactor(rescan):=20Phase=204=20Step=201=20?= =?UTF-8?q?=E2=80=94=20rescan=5Fshow=20on=20v2=20release=20repo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite rescan_show to build a SeriesRelease (Phase 1 v2 aggregate) and persist it via DotAlfredSeriesReleaseRepository. The orchestrator keeps reusing inspect_release as the single source of parse/probe truth — only the assembly target changes (SeriesRelease/SeasonRelease/ EpisodeRelease instead of TVShow/Season/Episode). New signature rescan_show( show_root, *, tmdb_id: TmdbId, imdb_id: ImdbId | None = None, series_repo: DotAlfredSeriesReleaseRepository, scanner, prober, kb, ) -> SeriesRelease Identity is TMDB-anchored (tmdb_id required, no coercion); imdb_id is optional. No TMDB call from rescan — the library index auto-heals from the new sidecar on its next read. PACK vs EPISODIC * Single-video + season-parsed + no-episode → SeasonRelease( mode=PACK, folder=, episodes=()). The slot map stays empty until the Phase 5 TMDB sync supplies episode_count. We do not fabricate an EpisodeRange we cannot prove on disk. * Otherwise → EPISODIC: every file with (season, episode) becomes an EpisodeRelease with EpisodeRange(start, end) = (E, E). Multi-episode files (S01E01E02) still record only the first slot — Parser does not yet expose episode_end (existing tech debt, unchanged). Package move The orchestrator moves from alfred/application/library/ to alfred/application/tv_shows/ for symmetry with alfred/application/ movies/ (Step 2). walker.py + its tests move with it. The empty library/ package is deleted. Tests tests/application/tv_shows/test_rescan.py rewritten end-to-end on the real v2 repository, real KB, real scanner, stubbed prober. 9 happy-path + edge-case scenarios cover EPISODIC track flattening, PACK empty-episodes semantics, sidecar round-trip, imdb_id optional, empty show root, season folder with no videos, prober returning None. test_walker.py moved verbatim (import path updated). Full suite: 1214 passed / 10 skipped / 4 xfailed. The three v1 dot_alfred quarantines from Phase 3 stay in place until Step 3. --- alfred/application/library/__init__.py | 13 -- alfred/application/library/rescan.py | 192 ---------------- alfred/application/tv_shows/__init__.py | 13 ++ alfred/application/tv_shows/rescan.py | 217 ++++++++++++++++++ .../{library => tv_shows}/walker.py | 0 .../{library => tv_shows}/__init__.py | 0 .../{library => tv_shows}/test_rescan.py | 181 ++++++++------- .../{library => tv_shows}/test_walker.py | 2 +- 8 files changed, 335 insertions(+), 283 deletions(-) delete mode 100644 alfred/application/library/__init__.py delete mode 100644 alfred/application/library/rescan.py create mode 100644 alfred/application/tv_shows/__init__.py create mode 100644 alfred/application/tv_shows/rescan.py rename alfred/application/{library => tv_shows}/walker.py (100%) rename tests/application/{library => tv_shows}/__init__.py (100%) rename tests/application/{library => tv_shows}/test_rescan.py (61%) rename tests/application/{library => tv_shows}/test_walker.py (98%) diff --git a/alfred/application/library/__init__.py b/alfred/application/library/__init__.py deleted file mode 100644 index 585a378..0000000 --- a/alfred/application/library/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Library orchestrators — operate on the Alfred-managed library tree. - -The library is a directory of show folders (one per TV show) where each -show holds season folders containing video files. Modules here walk -this tree and reconstruct domain aggregates by reusing the existing -release pipeline (``inspect_release``) rather than duplicating its -parse/probe logic. -""" - -from .rescan import rescan_show -from .walker import SeasonFolder, ShowTree, walk_show - -__all__ = ["SeasonFolder", "ShowTree", "rescan_show", "walk_show"] diff --git a/alfred/application/library/rescan.py b/alfred/application/library/rescan.py deleted file mode 100644 index 3b89fcf..0000000 --- a/alfred/application/library/rescan.py +++ /dev/null @@ -1,192 +0,0 @@ -"""``rescan_show`` — rebuild a TVShow aggregate from disk and persist it. - -The orchestrator walks the show folder, runs the existing release -pipeline (``inspect_release``) on every video file it finds, and -assembles the result into a frozen :class:`TVShow` via -:class:`TVShowBuilder`. The aggregate is then handed to the -repository for atomic persistence as a ``.alfred`` sidecar. - -Why reuse ``inspect_release``? -------------------------------- - -The "fresh download" flow already parses release names, picks a main -video, runs ffprobe and refines media type. We want exactly the same -intelligence applied to library content — running it again here -keeps a single source of truth for parsing/probing rules. The -orchestrator just has to translate per-file :class:`InspectedResult` -into builder calls. - -PACK vs EPISODIC ----------------- - -Detection lives in this layer (until the TMDB-driven -``ShowTracker`` arrives). The current rule: - -* A season folder containing exactly one video whose parser yields - ``season is not None`` and ``episode is None`` → **PACK**: the - season is recorded with empty ``episodes`` and tracks summarized - from the probe at the season level. -* Otherwise → **EPISODIC**: every file with a valid - ``(season, episode)`` becomes an :class:`Episode`. - -Files that fall outside both rules (no season parsed, mix of PACK + -episode files in the same folder, etc.) are logged and skipped — the -walker doesn't raise on corrupt input, and neither does the -orchestrator. - -Out of scope (tracked as tech debt): - -* Adjacent ``.srt`` files — only embedded subtitle tracks are captured. -* Multi-episode files (``S01E01E02``) — only the first episode is - recorded; ``episode_end`` is ignored. -* TMDB-driven PACK detection — for now PACK is inferred from the - on-disk file count + parser output. -""" - -from __future__ import annotations - -import logging -from pathlib import Path - -from alfred.application.library.walker import SeasonFolder, walk_show -from alfred.application.release.inspect import inspect_release -from alfred.domain.release.ports import ReleaseKnowledge -from alfred.domain.shared.media import MediaInfo -from alfred.domain.shared.ports import FilesystemScanner, MediaProber -from alfred.domain.shared.value_objects import FilePath, ImdbId -from alfred.domain.tv_shows.builders import SeasonBuilder, TVShowBuilder -from alfred.domain.tv_shows.entities import Episode, TVShow -from alfred.domain.tv_shows.repositories import TVShowRepository -from alfred.domain.tv_shows.value_objects import EpisodeNumber, SeasonNumber - -_LOG = logging.getLogger(__name__) - - -def rescan_show( - show_root: Path, - *, - imdb_id: ImdbId | str, - tmdb_id: int | None = None, - repository: TVShowRepository, - scanner: FilesystemScanner, - prober: MediaProber, - kb: ReleaseKnowledge, -) -> TVShow: - """Rebuild and persist the :class:`TVShow` aggregate for ``show_root``. - - The show's folder name is used as ``title`` (matching the - convention of :class:`DotAlfredTVShowRepository`). ``imdb_id`` and - ``tmdb_id`` are supplied by the caller — the orchestrator does - not call TMDB. - - Returns the rebuilt frozen aggregate (also written to disk by - ``repository.save``). - """ - tree = walk_show(show_root, scanner=scanner, kb=kb) - builder = TVShowBuilder( - imdb_id=imdb_id, title=show_root.name, tmdb_id=tmdb_id - ) - for season_folder in tree.season_folders: - _ingest_season(season_folder, show_root, builder, kb, prober) - show = builder.build() - repository.save(show) - return show - - -# --------------------------------------------------------------------------- # -# Per-season ingestion # -# --------------------------------------------------------------------------- # - - -def _ingest_season( - season_folder: SeasonFolder, - show_root: Path, - builder: TVShowBuilder, - kb: ReleaseKnowledge, - prober: MediaProber, -) -> None: - if not season_folder.video_files: - _LOG.warning( - "rescan_show: season folder %s contains no video file — skipping", - season_folder.season_dir, - ) - return - - # Inspect every video first; we need the whole batch to decide - # PACK vs EPISODIC before touching the builder. - inspected = [] - for video_path in season_folder.video_files: - result = inspect_release(video_path.name, video_path, kb, prober) - inspected.append((video_path, result)) - - seasons = {r.parsed.season for _, r in inspected if r.parsed.season is not None} - if not seasons: - _LOG.warning( - "rescan_show: no season number parsed in %s — skipping", - season_folder.season_dir, - ) - return - if len(seasons) > 1: - _LOG.warning( - "rescan_show: mixed season numbers %s in %s — skipping", - sorted(seasons), - season_folder.season_dir, - ) - return - season_number = SeasonNumber(seasons.pop()) - - # Single video, no episode → PACK. - if len(inspected) == 1 and inspected[0][1].parsed.episode is None: - video_path, result = inspected[0] - _ingest_pack(season_number, result.media_info, builder) - return - - # Otherwise treat every file as an EPISODIC entry. Files without - # a parseable episode number are logged and dropped. - for video_path, result in inspected: - if result.parsed.episode is None: - _LOG.warning( - "rescan_show: no episode number parsed for %s — skipping", - video_path, - ) - continue - episode = _make_episode( - season_number=season_number, - episode_number=EpisodeNumber(result.parsed.episode), - video_path=video_path, - show_root=show_root, - media_info=result.media_info, - ) - builder.add_episode(episode) - - -def _ingest_pack( - season_number: SeasonNumber, - media_info: MediaInfo | None, - builder: TVShowBuilder, -) -> None: - sb: SeasonBuilder = builder.season_builder(season_number) - if media_info is not None: - sb.set_audio_tracks(media_info.audio_tracks) - sb.set_subtitle_tracks(media_info.subtitle_tracks) - - -def _make_episode( - *, - season_number: SeasonNumber, - episode_number: EpisodeNumber, - video_path: Path, - show_root: Path, - media_info: MediaInfo | None, -) -> Episode: - rel_path = video_path.relative_to(show_root) - audio_tracks = media_info.audio_tracks if media_info else () - subtitle_tracks = media_info.subtitle_tracks if media_info else () - return Episode( - season_number=season_number, - episode_number=episode_number, - title="", - file_path=FilePath(str(rel_path)), - audio_tracks=audio_tracks, - subtitle_tracks=subtitle_tracks, - ) diff --git a/alfred/application/tv_shows/__init__.py b/alfred/application/tv_shows/__init__.py new file mode 100644 index 0000000..187c892 --- /dev/null +++ b/alfred/application/tv_shows/__init__.py @@ -0,0 +1,13 @@ +"""TV-show orchestrators — operate on the Alfred-managed TV library tree. + +The TV library is a directory of show folders (one per TV show), each +holding season folders containing video files. Modules here walk this +tree and reconstruct on-disk :class:`SeriesRelease` aggregates by +reusing the existing release pipeline (``inspect_release``) rather +than duplicating its parse/probe logic. +""" + +from .rescan import rescan_show +from .walker import SeasonFolder, ShowTree, walk_show + +__all__ = ["SeasonFolder", "ShowTree", "rescan_show", "walk_show"] diff --git a/alfred/application/tv_shows/rescan.py b/alfred/application/tv_shows/rescan.py new file mode 100644 index 0000000..aa45807 --- /dev/null +++ b/alfred/application/tv_shows/rescan.py @@ -0,0 +1,217 @@ +"""``rescan_show`` — rebuild a SeriesRelease from disk and persist it. + +The orchestrator walks the show folder, runs the existing release +pipeline (``inspect_release``) on every video file, and assembles the +result into a frozen :class:`SeriesRelease` written to the per-show +v2 ``.alfred`` sidecar. + +Why reuse ``inspect_release``? +------------------------------- + +The "fresh download" flow already parses release names, picks a main +video, runs ffprobe and refines media type. We want exactly the same +intelligence applied to library content — running it again here keeps +a single source of truth for parsing / probing rules. The orchestrator +just translates per-file :class:`InspectedResult` into release +aggregate construction. + +PACK vs EPISODIC detection +--------------------------- + +Detection lives in this layer (until the TMDB-driven season-tracker +arrives). The current rule: + +* A season folder containing exactly one video whose parser yields + ``season is not None`` and ``episode is None`` → **PACK** with an + empty ``episodes`` tuple. We record the season's mode + folder, but + we cannot fill the episode slot map without TMDB's + ``episode_count`` — that's Phase 5's job. The file is still on + disk; the next TMDB sync repairs the slot map. +* Otherwise → **EPISODIC**: every file with a valid ``(season, + episode)`` becomes an :class:`EpisodeRelease`. Multi-episode files + (``S01E01E02``) are recorded once with a wide ``EpisodeRange``; + Phase 4 only handles the single-episode case (the parser does not + yet expose ``episode_end`` on ``ParsedRelease``). + +Files that fall outside both rules (no season parsed, mixed season +numbers in a folder, etc.) are logged and skipped — the walker doesn't +raise on corrupt input, and neither does the orchestrator. + +TMDB +---- + +``rescan_show`` does **not** call TMDB. It writes the release +sidecar; the library index is updated transparently by its auto-heal +path on the next read. A subsequent TMDB sync (Phase 5) layers +identity / season cache facts on top of the on-disk truth. + +Out of scope (tracked as tech debt): + +* Adjacent ``.srt`` files — only embedded subtitle tracks are + captured. +* Multi-episode files — ``ParsedRelease`` has no ``episode_end`` + field yet. +""" + +from __future__ import annotations + +import logging +from pathlib import Path + +from alfred.application.release.inspect import inspect_release +from alfred.application.tv_shows.walker import SeasonFolder, walk_show +from alfred.domain.release.ports import ReleaseKnowledge +from alfred.domain.releases.entities import ( + EpisodeRelease, + SeasonRelease, + SeriesRelease, + TrackProfile, +) +from alfred.domain.releases.value_objects import EpisodeRange, ReleaseMode +from alfred.domain.shared.media import MediaInfo +from alfred.domain.shared.ports import FilesystemScanner, MediaProber +from alfred.domain.shared.value_objects import FilePath, ImdbId, TmdbId +from alfred.domain.tv_shows.value_objects import EpisodeNumber, SeasonNumber +from alfred.infrastructure.persistence.dot_alfred.v2.repository import ( + DotAlfredSeriesReleaseRepository, +) + +_LOG = logging.getLogger(__name__) + + +def rescan_show( + show_root: Path, + *, + tmdb_id: TmdbId, + imdb_id: ImdbId | None = None, + series_repo: DotAlfredSeriesReleaseRepository, + scanner: FilesystemScanner, + prober: MediaProber, + kb: ReleaseKnowledge, +) -> SeriesRelease: + """Rebuild and persist the :class:`SeriesRelease` for ``show_root``. + + The show's folder name (``show_root.name``) is used as the sidecar + location relative to the library root. TMDB identity comes from the + caller — the orchestrator does not call TMDB. + + Returns the rebuilt frozen aggregate (also written to disk by + ``series_repo.save``). + """ + tree = walk_show(show_root, scanner=scanner, kb=kb) + seasons: list[SeasonRelease] = [] + for season_folder in tree.season_folders: + season = _ingest_season(season_folder, show_root, kb, prober) + if season is not None: + seasons.append(season) + release = SeriesRelease( + tmdb_id=tmdb_id, + imdb_id=imdb_id, + seasons=tuple(seasons), + ) + series_repo.save(release, show_folder=show_root.name) + return release + + +# --------------------------------------------------------------------------- # +# Per-season ingestion # +# --------------------------------------------------------------------------- # + + +def _ingest_season( + season_folder: SeasonFolder, + show_root: Path, + kb: ReleaseKnowledge, + prober: MediaProber, +) -> SeasonRelease | None: + if not season_folder.video_files: + _LOG.warning( + "rescan_show: season folder %s contains no video file — skipping", + season_folder.season_dir, + ) + return None + + # Inspect every video first; we need the whole batch to decide + # PACK vs EPISODIC before assembling the SeasonRelease. + inspected = [] + for video_path in season_folder.video_files: + result = inspect_release(video_path.name, video_path, kb, prober) + inspected.append((video_path, result)) + + season_numbers = { + r.parsed.season for _, r in inspected if r.parsed.season is not None + } + if not season_numbers: + _LOG.warning( + "rescan_show: no season number parsed in %s — skipping", + season_folder.season_dir, + ) + return None + if len(season_numbers) > 1: + _LOG.warning( + "rescan_show: mixed season numbers %s in %s — skipping", + sorted(season_numbers), + season_folder.season_dir, + ) + return None + season_number = SeasonNumber(season_numbers.pop()) + folder_name = season_folder.season_dir.name + + # Single video, no episode → PACK with empty episodes. We can't + # synthesize an EpisodeRange without TMDB's episode_count; the + # Phase 5 sync repairs the slot map. Track info from the PACK + # file is intentionally not persisted here — re-derivable on the + # next rescan after the sync fills the range. + if len(inspected) == 1 and inspected[0][1].parsed.episode is None: + return SeasonRelease( + season_number=season_number, + folder=folder_name, + mode=ReleaseMode.PACK, + episodes=(), + ) + + # EPISODIC: every file with a parseable episode number becomes an + # EpisodeRelease. Files without an episode number are logged and + # dropped (a mixed PACK/EPISODIC folder is malformed). + episodes: list[EpisodeRelease] = [] + for video_path, result in inspected: + if result.parsed.episode is None: + _LOG.warning( + "rescan_show: no episode number parsed for %s — skipping", + video_path, + ) + continue + episodes.append( + _make_episode_release( + episode_number=EpisodeNumber(result.parsed.episode), + video_path=video_path, + show_root=show_root, + media_info=result.media_info, + ) + ) + return SeasonRelease( + season_number=season_number, + folder=folder_name, + mode=ReleaseMode.EPISODIC, + episodes=tuple(episodes), + ) + + +def _make_episode_release( + *, + episode_number: EpisodeNumber, + video_path: Path, + show_root: Path, + media_info: MediaInfo | None, +) -> EpisodeRelease: + rel_path = video_path.relative_to(show_root) + audio_tracks = media_info.audio_tracks if media_info else () + subtitle_tracks = media_info.subtitle_tracks if media_info else () + return EpisodeRelease( + episodes=EpisodeRange(start=episode_number, end=episode_number), + file_path=FilePath(str(rel_path)), + tracks=TrackProfile( + audio_tracks=audio_tracks, + subtitle_tracks=subtitle_tracks, + ), + ) diff --git a/alfred/application/library/walker.py b/alfred/application/tv_shows/walker.py similarity index 100% rename from alfred/application/library/walker.py rename to alfred/application/tv_shows/walker.py diff --git a/tests/application/library/__init__.py b/tests/application/tv_shows/__init__.py similarity index 100% rename from tests/application/library/__init__.py rename to tests/application/tv_shows/__init__.py diff --git a/tests/application/library/test_rescan.py b/tests/application/tv_shows/test_rescan.py similarity index 61% rename from tests/application/library/test_rescan.py rename to tests/application/tv_shows/test_rescan.py index 372a344..f4971e5 100644 --- a/tests/application/library/test_rescan.py +++ b/tests/application/tv_shows/test_rescan.py @@ -1,47 +1,36 @@ -"""Integration tests for ``rescan_show``. +"""Integration tests for the v2 ``rescan_show``. Uses the real filesystem (``tmp_path``), the real release knowledge -base, and the real ``.alfred`` repository. Only the media prober is -stubbed — ffprobe needs real bytes and a binary. +base, the real v2 ``.alfred`` series repository, and the real scanner. +Only the media prober is stubbed — ffprobe needs real bytes and a +binary. """ from __future__ import annotations -import pytest - -# Phase 3 (refactor/dot-alfred-v2): the v1 rescan_show + v1 -# DotAlfredTVShowRepository stack is intentionally left broken -# while the TVShow/Movie aggregates are slimmed to TMDB-only. -# Phase 4 rewrites rescan on top of the v2 release repositories + -# library index, then deletes this quarantine block alongside the -# v1 code. -pytest.skip( - "v1 rescan + v1 dot_alfred — replaced in Phase 4", - allow_module_level=True, -) - from pathlib import Path -from alfred.application.library import rescan_show +from alfred.application.tv_shows import rescan_show +from alfred.domain.releases.value_objects import ReleaseMode from alfred.domain.shared.media import ( AudioTrack, MediaInfo, SubtitleTrack, VideoTrack, ) -from alfred.domain.shared.value_objects import ImdbId +from alfred.domain.shared.value_objects import ImdbId, TmdbId from alfred.domain.tv_shows.value_objects import SeasonNumber from alfred.infrastructure.filesystem.scanner import PathlibFilesystemScanner from alfred.infrastructure.knowledge.release_kb import YamlReleaseKnowledge -from alfred.infrastructure.persistence.dot_alfred import ( - DotAlfredTVShowRepository, -) -from alfred.infrastructure.persistence.dot_alfred.repository import ( +from alfred.infrastructure.persistence.dot_alfred.v2.repository import ( SIDECAR_FILENAME, + DotAlfredSeriesReleaseRepository, ) _KB = YamlReleaseKnowledge() _SCANNER = PathlibFilesystemScanner() +_TMDB_ID = TmdbId(84958) +_IMDB_ID = ImdbId("tt0804484") # --------------------------------------------------------------------------- # @@ -101,7 +90,7 @@ def _make_foundation_library( ) -> Path: """Build a fake Foundation show folder under ``root``. - Layout (matches the Foundation fixture naming): + Layout:: root/Foundation/ Foundation.S01.1080p.x265-ELiTE/ ← EPISODIC @@ -138,84 +127,109 @@ def _library_with_show(tmp_path: Path) -> tuple[Path, Path]: class TestHappyPath: - def test_builds_show_with_episodic_and_pack_seasons(self, tmp_path): + def test_builds_release_with_episodic_and_pack_seasons(self, tmp_path): library, show_root = _library_with_show(tmp_path) - repo = DotAlfredTVShowRepository(library) + repo = DotAlfredSeriesReleaseRepository(library) prober = _StubProber() - show = rescan_show( + release = rescan_show( show_root, - imdb_id="tt0804484", - tmdb_id=84958, - repository=repo, + tmdb_id=_TMDB_ID, + imdb_id=_IMDB_ID, + series_repo=repo, scanner=_SCANNER, prober=prober, kb=_KB, ) - assert show.imdb_id == ImdbId("tt0804484") - assert show.tmdb_id == 84958 - assert show.title == "Foundation" - assert show.seasons_count == 2 + assert release.tmdb_id == _TMDB_ID + assert release.imdb_id == _IMDB_ID + assert len(release.seasons) == 2 - s1 = show.get_season(SeasonNumber(1)) - s2 = show.get_season(SeasonNumber(2)) + s1 = release.get_season(SeasonNumber(1)) + s2 = release.get_season(SeasonNumber(2)) assert s1 is not None and s2 is not None - # S01 EPISODIC: three episodes, season-level tracks empty. - assert s1.episode_count == 3 - assert s1.audio_tracks == () - assert s1.subtitle_tracks == () - # S02 PACK: no episodes, season-level tracks populated. - assert s2.episode_count == 0 - assert len(s2.audio_tracks) == 1 - assert s2.audio_tracks[0].language == "eng" - assert len(s2.subtitle_tracks) == 1 + + # S01 EPISODIC: three single-episode releases, each carrying + # its own TrackProfile. + assert s1.mode is ReleaseMode.EPISODIC + assert s1.episode_count() == 3 + for ep in s1.episodes: + assert ep.episodes.is_single() + assert len(ep.tracks.audio_tracks) == 1 + assert ep.tracks.audio_tracks[0].language == "eng" + assert len(ep.tracks.subtitle_tracks) == 1 + + # S02 PACK: empty episodes tuple. Phase 5 (TMDB sync) will + # populate the slot map once episode_count is known. + assert s2.mode is ReleaseMode.PACK + assert s2.episodes == () def test_episode_paths_are_relative_to_show_root(self, tmp_path): library, show_root = _library_with_show(tmp_path) - repo = DotAlfredTVShowRepository(library) - show = rescan_show( + repo = DotAlfredSeriesReleaseRepository(library) + release = rescan_show( show_root, - imdb_id="tt0804484", - repository=repo, + tmdb_id=_TMDB_ID, + imdb_id=_IMDB_ID, + series_repo=repo, scanner=_SCANNER, prober=_StubProber(), kb=_KB, ) - s1 = show.get_season(SeasonNumber(1)) + s1 = release.get_season(SeasonNumber(1)) assert s1 is not None for ep in s1.episodes: - assert ep.file_path is not None path_str = str(ep.file_path) # Must NOT be absolute and must start with the season folder. assert not Path(path_str).is_absolute() assert path_str.startswith("Foundation.S01.1080p.x265-ELiTE/") + def test_season_folder_name_recorded(self, tmp_path): + library, show_root = _library_with_show(tmp_path) + repo = DotAlfredSeriesReleaseRepository(library) + release = rescan_show( + show_root, + tmdb_id=_TMDB_ID, + imdb_id=_IMDB_ID, + series_repo=repo, + scanner=_SCANNER, + prober=_StubProber(), + kb=_KB, + ) + s1 = release.get_season(SeasonNumber(1)) + s2 = release.get_season(SeasonNumber(2)) + assert s1 is not None and s2 is not None + assert s1.folder == "Foundation.S01.1080p.x265-ELiTE" + assert s2.folder == "Foundation.S02.1080p.x265-ELiTE" + def test_persists_sidecar_on_disk(self, tmp_path): library, show_root = _library_with_show(tmp_path) - repo = DotAlfredTVShowRepository(library) + repo = DotAlfredSeriesReleaseRepository(library) rescan_show( show_root, - imdb_id="tt0804484", - repository=repo, + tmdb_id=_TMDB_ID, + imdb_id=_IMDB_ID, + series_repo=repo, scanner=_SCANNER, prober=_StubProber(), kb=_KB, ) assert (show_root / SIDECAR_FILENAME).is_file() # Round-trip via the repo. - recovered = repo.find_by_imdb_id(ImdbId("tt0804484")) + recovered = repo.find_by_tmdb_id(_TMDB_ID) assert recovered is not None - assert recovered.seasons_count == 2 + assert len(recovered.seasons) == 2 def test_probe_called_once_per_video(self, tmp_path): library, show_root = _library_with_show(tmp_path) - repo = DotAlfredTVShowRepository(library) + repo = DotAlfredSeriesReleaseRepository(library) prober = _StubProber() rescan_show( show_root, - imdb_id="tt0804484", - repository=repo, + tmdb_id=_TMDB_ID, + imdb_id=_IMDB_ID, + series_repo=repo, scanner=_SCANNER, prober=prober, kb=_KB, @@ -223,6 +237,19 @@ class TestHappyPath: # 3 episodes + 1 pack video = 4 probes. assert len(prober.calls) == 4 + def test_imdb_id_optional(self, tmp_path): + library, show_root = _library_with_show(tmp_path) + repo = DotAlfredSeriesReleaseRepository(library) + release = rescan_show( + show_root, + tmdb_id=_TMDB_ID, + series_repo=repo, + scanner=_SCANNER, + prober=_StubProber(), + kb=_KB, + ) + assert release.imdb_id is None + # --------------------------------------------------------------------------- # # Edge cases # @@ -230,21 +257,21 @@ class TestHappyPath: class TestEdgeCases: - def test_empty_show_root_yields_empty_show(self, tmp_path): + def test_empty_show_root_yields_empty_release(self, tmp_path): library = tmp_path / "library" library.mkdir() show_root = library / "Empty" show_root.mkdir() - repo = DotAlfredTVShowRepository(library) - show = rescan_show( + repo = DotAlfredSeriesReleaseRepository(library) + release = rescan_show( show_root, - imdb_id="tt0000001", - repository=repo, + tmdb_id=_TMDB_ID, + series_repo=repo, scanner=_SCANNER, prober=_StubProber(), kb=_KB, ) - assert show.seasons_count == 0 + assert release.seasons == () assert (show_root / SIDECAR_FILENAME).is_file() def test_season_folder_without_videos_is_skipped(self, tmp_path, caplog): @@ -253,17 +280,17 @@ class TestEdgeCases: show_root = library / "Foundation" show_root.mkdir() (show_root / "Foundation.S01.WEB").mkdir() # empty season folder - repo = DotAlfredTVShowRepository(library) + repo = DotAlfredSeriesReleaseRepository(library) with caplog.at_level("WARNING"): - show = rescan_show( + release = rescan_show( show_root, - imdb_id="tt0804484", - repository=repo, + tmdb_id=_TMDB_ID, + series_repo=repo, scanner=_SCANNER, prober=_StubProber(), kb=_KB, ) - assert show.seasons_count == 0 + assert release.seasons == () assert any("no video file" in r.message for r in caplog.records) def test_prober_returning_none_still_produces_episodes(self, tmp_path): @@ -272,19 +299,19 @@ class TestEdgeCases: show_root = _make_foundation_library( library, episodic_episodes=(1,), include_pack_s2=False ) - repo = DotAlfredTVShowRepository(library) + repo = DotAlfredSeriesReleaseRepository(library) # Prober returns None — inspect_release skips enrichment, tracks empty. - show = rescan_show( + release = rescan_show( show_root, - imdb_id="tt0804484", - repository=repo, + tmdb_id=_TMDB_ID, + series_repo=repo, scanner=_SCANNER, prober=_StubProber(info=None), kb=_KB, ) - s1 = show.get_season(SeasonNumber(1)) + s1 = release.get_season(SeasonNumber(1)) assert s1 is not None - assert s1.episode_count == 1 + assert s1.episode_count() == 1 ep = s1.episodes[0] - assert ep.audio_tracks == () - assert ep.subtitle_tracks == () + assert ep.tracks.audio_tracks == () + assert ep.tracks.subtitle_tracks == () diff --git a/tests/application/library/test_walker.py b/tests/application/tv_shows/test_walker.py similarity index 98% rename from tests/application/library/test_walker.py rename to tests/application/tv_shows/test_walker.py index d916175..dbd9c43 100644 --- a/tests/application/library/test_walker.py +++ b/tests/application/tv_shows/test_walker.py @@ -2,7 +2,7 @@ from __future__ import annotations -from alfred.application.library.walker import walk_show +from alfred.application.tv_shows.walker import walk_show from alfred.infrastructure.filesystem.scanner import PathlibFilesystemScanner from alfred.infrastructure.knowledge.release_kb import YamlReleaseKnowledge