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:
@@ -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"]
|
|
||||||
@@ -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,
|
|
||||||
)
|
|
||||||
@@ -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"]
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
)
|
||||||
+104
-77
@@ -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 == ()
|
||||||
+1
-1
@@ -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
|
||||||
|
|
||||||
Reference in New Issue
Block a user