refactor(rescan): Phase 4 Step 1 — rescan_show on v2 release repo

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=<season 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.
This commit is contained in:
2026-05-25 21:07:25 +02:00
parent c22b2b78eb
commit 7da0f887e7
8 changed files with 335 additions and 283 deletions
-13
View File
@@ -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"]
-192
View File
@@ -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,
)
+13
View File
@@ -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"]
+217
View File
@@ -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,
),
)
@@ -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 Uses the real filesystem (``tmp_path``), the real release knowledge
base, and the real ``.alfred`` repository. Only the media prober is base, the real v2 ``.alfred`` series repository, and the real scanner.
stubbed ffprobe needs real bytes and a binary. Only the media prober is stubbed ffprobe needs real bytes and a
binary.
""" """
from __future__ import annotations 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 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 ( from alfred.domain.shared.media import (
AudioTrack, AudioTrack,
MediaInfo, MediaInfo,
SubtitleTrack, SubtitleTrack,
VideoTrack, 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.domain.tv_shows.value_objects import SeasonNumber
from alfred.infrastructure.filesystem.scanner import PathlibFilesystemScanner from alfred.infrastructure.filesystem.scanner import PathlibFilesystemScanner
from alfred.infrastructure.knowledge.release_kb import YamlReleaseKnowledge from alfred.infrastructure.knowledge.release_kb import YamlReleaseKnowledge
from alfred.infrastructure.persistence.dot_alfred import ( from alfred.infrastructure.persistence.dot_alfred.v2.repository import (
DotAlfredTVShowRepository,
)
from alfred.infrastructure.persistence.dot_alfred.repository import (
SIDECAR_FILENAME, SIDECAR_FILENAME,
DotAlfredSeriesReleaseRepository,
) )
_KB = YamlReleaseKnowledge() _KB = YamlReleaseKnowledge()
_SCANNER = PathlibFilesystemScanner() _SCANNER = PathlibFilesystemScanner()
_TMDB_ID = TmdbId(84958)
_IMDB_ID = ImdbId("tt0804484")
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -101,7 +90,7 @@ def _make_foundation_library(
) -> Path: ) -> Path:
"""Build a fake Foundation show folder under ``root``. """Build a fake Foundation show folder under ``root``.
Layout (matches the Foundation fixture naming): Layout::
root/Foundation/ root/Foundation/
Foundation.S01.1080p.x265-ELiTE/ EPISODIC Foundation.S01.1080p.x265-ELiTE/ EPISODIC
@@ -138,84 +127,109 @@ def _library_with_show(tmp_path: Path) -> tuple[Path, Path]:
class TestHappyPath: 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) library, show_root = _library_with_show(tmp_path)
repo = DotAlfredTVShowRepository(library) repo = DotAlfredSeriesReleaseRepository(library)
prober = _StubProber() prober = _StubProber()
show = rescan_show( release = rescan_show(
show_root, show_root,
imdb_id="tt0804484", tmdb_id=_TMDB_ID,
tmdb_id=84958, imdb_id=_IMDB_ID,
repository=repo, series_repo=repo,
scanner=_SCANNER, scanner=_SCANNER,
prober=prober, prober=prober,
kb=_KB, kb=_KB,
) )
assert show.imdb_id == ImdbId("tt0804484") assert release.tmdb_id == _TMDB_ID
assert show.tmdb_id == 84958 assert release.imdb_id == _IMDB_ID
assert show.title == "Foundation" assert len(release.seasons) == 2
assert show.seasons_count == 2
s1 = show.get_season(SeasonNumber(1)) s1 = release.get_season(SeasonNumber(1))
s2 = show.get_season(SeasonNumber(2)) s2 = release.get_season(SeasonNumber(2))
assert s1 is not None and s2 is not None assert s1 is not None and s2 is not None
# S01 EPISODIC: three episodes, season-level tracks empty.
assert s1.episode_count == 3 # S01 EPISODIC: three single-episode releases, each carrying
assert s1.audio_tracks == () # its own TrackProfile.
assert s1.subtitle_tracks == () assert s1.mode is ReleaseMode.EPISODIC
# S02 PACK: no episodes, season-level tracks populated. assert s1.episode_count() == 3
assert s2.episode_count == 0 for ep in s1.episodes:
assert len(s2.audio_tracks) == 1 assert ep.episodes.is_single()
assert s2.audio_tracks[0].language == "eng" assert len(ep.tracks.audio_tracks) == 1
assert len(s2.subtitle_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): def test_episode_paths_are_relative_to_show_root(self, tmp_path):
library, show_root = _library_with_show(tmp_path) library, show_root = _library_with_show(tmp_path)
repo = DotAlfredTVShowRepository(library) repo = DotAlfredSeriesReleaseRepository(library)
show = rescan_show( release = rescan_show(
show_root, show_root,
imdb_id="tt0804484", tmdb_id=_TMDB_ID,
repository=repo, imdb_id=_IMDB_ID,
series_repo=repo,
scanner=_SCANNER, scanner=_SCANNER,
prober=_StubProber(), prober=_StubProber(),
kb=_KB, kb=_KB,
) )
s1 = show.get_season(SeasonNumber(1)) s1 = release.get_season(SeasonNumber(1))
assert s1 is not None assert s1 is not None
for ep in s1.episodes: for ep in s1.episodes:
assert ep.file_path is not None
path_str = str(ep.file_path) path_str = str(ep.file_path)
# Must NOT be absolute and must start with the season folder. # Must NOT be absolute and must start with the season folder.
assert not Path(path_str).is_absolute() assert not Path(path_str).is_absolute()
assert path_str.startswith("Foundation.S01.1080p.x265-ELiTE/") 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): def test_persists_sidecar_on_disk(self, tmp_path):
library, show_root = _library_with_show(tmp_path) library, show_root = _library_with_show(tmp_path)
repo = DotAlfredTVShowRepository(library) repo = DotAlfredSeriesReleaseRepository(library)
rescan_show( rescan_show(
show_root, show_root,
imdb_id="tt0804484", tmdb_id=_TMDB_ID,
repository=repo, imdb_id=_IMDB_ID,
series_repo=repo,
scanner=_SCANNER, scanner=_SCANNER,
prober=_StubProber(), prober=_StubProber(),
kb=_KB, kb=_KB,
) )
assert (show_root / SIDECAR_FILENAME).is_file() assert (show_root / SIDECAR_FILENAME).is_file()
# Round-trip via the repo. # 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 is not None
assert recovered.seasons_count == 2 assert len(recovered.seasons) == 2
def test_probe_called_once_per_video(self, tmp_path): def test_probe_called_once_per_video(self, tmp_path):
library, show_root = _library_with_show(tmp_path) library, show_root = _library_with_show(tmp_path)
repo = DotAlfredTVShowRepository(library) repo = DotAlfredSeriesReleaseRepository(library)
prober = _StubProber() prober = _StubProber()
rescan_show( rescan_show(
show_root, show_root,
imdb_id="tt0804484", tmdb_id=_TMDB_ID,
repository=repo, imdb_id=_IMDB_ID,
series_repo=repo,
scanner=_SCANNER, scanner=_SCANNER,
prober=prober, prober=prober,
kb=_KB, kb=_KB,
@@ -223,6 +237,19 @@ class TestHappyPath:
# 3 episodes + 1 pack video = 4 probes. # 3 episodes + 1 pack video = 4 probes.
assert len(prober.calls) == 4 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 # # Edge cases #
@@ -230,21 +257,21 @@ class TestHappyPath:
class TestEdgeCases: 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 = tmp_path / "library"
library.mkdir() library.mkdir()
show_root = library / "Empty" show_root = library / "Empty"
show_root.mkdir() show_root.mkdir()
repo = DotAlfredTVShowRepository(library) repo = DotAlfredSeriesReleaseRepository(library)
show = rescan_show( release = rescan_show(
show_root, show_root,
imdb_id="tt0000001", tmdb_id=_TMDB_ID,
repository=repo, series_repo=repo,
scanner=_SCANNER, scanner=_SCANNER,
prober=_StubProber(), prober=_StubProber(),
kb=_KB, kb=_KB,
) )
assert show.seasons_count == 0 assert release.seasons == ()
assert (show_root / SIDECAR_FILENAME).is_file() assert (show_root / SIDECAR_FILENAME).is_file()
def test_season_folder_without_videos_is_skipped(self, tmp_path, caplog): def test_season_folder_without_videos_is_skipped(self, tmp_path, caplog):
@@ -253,17 +280,17 @@ class TestEdgeCases:
show_root = library / "Foundation" show_root = library / "Foundation"
show_root.mkdir() show_root.mkdir()
(show_root / "Foundation.S01.WEB").mkdir() # empty season folder (show_root / "Foundation.S01.WEB").mkdir() # empty season folder
repo = DotAlfredTVShowRepository(library) repo = DotAlfredSeriesReleaseRepository(library)
with caplog.at_level("WARNING"): with caplog.at_level("WARNING"):
show = rescan_show( release = rescan_show(
show_root, show_root,
imdb_id="tt0804484", tmdb_id=_TMDB_ID,
repository=repo, series_repo=repo,
scanner=_SCANNER, scanner=_SCANNER,
prober=_StubProber(), prober=_StubProber(),
kb=_KB, kb=_KB,
) )
assert show.seasons_count == 0 assert release.seasons == ()
assert any("no video file" in r.message for r in caplog.records) assert any("no video file" in r.message for r in caplog.records)
def test_prober_returning_none_still_produces_episodes(self, tmp_path): def test_prober_returning_none_still_produces_episodes(self, tmp_path):
@@ -272,19 +299,19 @@ class TestEdgeCases:
show_root = _make_foundation_library( show_root = _make_foundation_library(
library, episodic_episodes=(1,), include_pack_s2=False library, episodic_episodes=(1,), include_pack_s2=False
) )
repo = DotAlfredTVShowRepository(library) repo = DotAlfredSeriesReleaseRepository(library)
# Prober returns None — inspect_release skips enrichment, tracks empty. # Prober returns None — inspect_release skips enrichment, tracks empty.
show = rescan_show( release = rescan_show(
show_root, show_root,
imdb_id="tt0804484", tmdb_id=_TMDB_ID,
repository=repo, series_repo=repo,
scanner=_SCANNER, scanner=_SCANNER,
prober=_StubProber(info=None), prober=_StubProber(info=None),
kb=_KB, kb=_KB,
) )
s1 = show.get_season(SeasonNumber(1)) s1 = release.get_season(SeasonNumber(1))
assert s1 is not None assert s1 is not None
assert s1.episode_count == 1 assert s1.episode_count() == 1
ep = s1.episodes[0] ep = s1.episodes[0]
assert ep.audio_tracks == () assert ep.tracks.audio_tracks == ()
assert ep.subtitle_tracks == () assert ep.tracks.subtitle_tracks == ()
@@ -2,7 +2,7 @@
from __future__ import annotations 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.filesystem.scanner import PathlibFilesystemScanner
from alfred.infrastructure.knowledge.release_kb import YamlReleaseKnowledge from alfred.infrastructure.knowledge.release_kb import YamlReleaseKnowledge