de7030fa9c
Step 4 of specs/dot_alfred.md — rebuild a TVShow aggregate from disk
by reusing the existing release pipeline (inspect_release) on every
video file in a show folder, then persist via the .alfred repository.
- alfred/application/library/walker.py — pure structural walk
(season folders detected via \bS\d{1,2}\b regex, video files
filtered against kb.video_extensions, no recursion).
- alfred/application/library/rescan.py — orchestrator that ingests
each season folder, infers PACK vs EPISODIC from on-disk file
count + parser output, and assembles via TVShowBuilder. Episode
paths stored relative to show_root. Logs + skips corrupt input
(no season parsed, mixed season numbers, unparseable episodes).
- Season now inherits MediaWithTracks: PACK seasons carry
season-level audio_tracks / subtitle_tracks; EPISODIC seasons
leave them empty (tracks live per-episode). SeasonBuilder gains
set_audio_tracks / set_subtitle_tracks; bridge writes/reads them
in the PACK branch via shared _synth_* helpers.
Out of scope, tracked as tech debt: adjacent .srt capture, multi-
episode (episode_end), TMDB-driven PACK detection (the current
heuristic '1 file == PACK' is a placeholder until ShowTracker lands).
18 new tests (11 walker + 7 rescan integration) on tmp_path with
the Foundation layout. Full suite: 1149 passed.
185 lines
6.8 KiB
Python
185 lines
6.8 KiB
Python
"""Bridge between the ``.alfred`` sidecar DTOs and the TVShow aggregate.
|
|
|
|
The sidecar stores a **summary** of the probe (audio languages,
|
|
subtitle entries with source + type) — not the full ffprobe output.
|
|
Going back to the domain we synthesize ``AudioTrack`` and
|
|
``SubtitleTrack`` objects with only the fields the sidecar preserved:
|
|
|
|
* ``AudioTrack`` — one per language, ``codec`` / ``channels`` /
|
|
``channel_layout`` / ``is_default`` left as ``None`` / ``False``.
|
|
* ``SubtitleTrack`` — one per :class:`SubtitleEntry`, ``codec`` /
|
|
``is_default`` left as ``None`` / ``False``; ``is_forced`` derived
|
|
from ``entry.type == "forced"``.
|
|
|
|
The reverse path (TVShow → sidecar) summarizes the rich tracks back to
|
|
the sidecar shape: unique audio languages preserving track order,
|
|
subtitle entries built from the track flags (``is_forced`` →
|
|
``type="forced"``, otherwise ``type="standard"``; ``source="embedded"``
|
|
since the domain track represents an in-container stream).
|
|
|
|
External subtitles (``source="adjacent"``) are not currently surfaced
|
|
by the domain track model — they exist on disk next to the file but
|
|
the aggregate has no slot for them today. They will be lossless once
|
|
the subtitle scan layer feeds them in; for now the bridge round-trips
|
|
the embedded subs only.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from ....domain.shared.media import AudioTrack, SubtitleTrack
|
|
from ....domain.shared.value_objects import FilePath
|
|
from ....domain.tv_shows.builders import SeasonBuilder, TVShowBuilder
|
|
from ....domain.tv_shows.entities import Episode, Season, TVShow
|
|
from ....domain.tv_shows.value_objects import SeasonNumber
|
|
from .sidecar import (
|
|
EpisodeSidecar,
|
|
SeasonSidecar,
|
|
ShowSidecar,
|
|
SubtitleEntry,
|
|
)
|
|
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
# TVShow → ShowSidecar
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
def to_sidecar(show: TVShow, *, folder_paths: dict[int, str]) -> ShowSidecar:
|
|
"""Build a :class:`ShowSidecar` from a domain :class:`TVShow`.
|
|
|
|
``folder_paths`` maps season numbers to the on-disk folder name
|
|
(relative to the show root). Required because the domain does not
|
|
carry the source folder name; the caller (repository) knows it.
|
|
"""
|
|
seasons = tuple(
|
|
_season_to_sidecar(s, folder_paths[s.season_number.value])
|
|
for s in show.seasons
|
|
)
|
|
return ShowSidecar(
|
|
imdb_id=show.imdb_id,
|
|
tmdb_id=show.tmdb_id,
|
|
seasons=seasons,
|
|
)
|
|
|
|
|
|
def _season_to_sidecar(season: Season, path: str) -> SeasonSidecar:
|
|
if season.episodes:
|
|
# EPISODIC mode — tracks live on each episode.
|
|
return SeasonSidecar(
|
|
number=season.season_number,
|
|
path=path,
|
|
episodes=tuple(_episode_to_sidecar(ep) for ep in season.episodes),
|
|
)
|
|
|
|
# PACK mode — season-scoped tracks (single release covering the whole
|
|
# season). Summarize the same way as for episodes.
|
|
return SeasonSidecar(
|
|
number=season.season_number,
|
|
path=path,
|
|
audio_languages=tuple(season.audio_languages()),
|
|
subtitles=tuple(
|
|
_subtitle_track_to_entry(t) for t in season.subtitle_tracks
|
|
),
|
|
)
|
|
|
|
|
|
def _episode_to_sidecar(episode: Episode) -> EpisodeSidecar:
|
|
if episode.file_path is None:
|
|
raise ValueError(
|
|
f"cannot serialize episode {episode!r} without a file_path"
|
|
)
|
|
audio_languages = tuple(episode.audio_languages())
|
|
subtitles = tuple(_subtitle_track_to_entry(t) for t in episode.subtitle_tracks)
|
|
return EpisodeSidecar(
|
|
number=episode.episode_number,
|
|
path=str(episode.file_path),
|
|
audio_languages=audio_languages,
|
|
subtitles=subtitles,
|
|
)
|
|
|
|
|
|
def _subtitle_track_to_entry(track: SubtitleTrack) -> SubtitleEntry:
|
|
return SubtitleEntry(
|
|
language=track.language or "und",
|
|
source="embedded",
|
|
type="forced" if track.is_forced else "standard",
|
|
)
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
# ShowSidecar → TVShow
|
|
# ════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
def from_sidecar(sidecar: ShowSidecar, *, title: str) -> TVShow:
|
|
"""Reconstruct a :class:`TVShow` from a sidecar.
|
|
|
|
``title`` must be supplied by the caller — the sidecar stores
|
|
identity (``imdb_id`` / ``tmdb_id``) but not the display title; the
|
|
repository derives it from the folder name on disk.
|
|
"""
|
|
builder = TVShowBuilder(
|
|
imdb_id=sidecar.imdb_id,
|
|
title=title,
|
|
tmdb_id=sidecar.tmdb_id,
|
|
)
|
|
for season in sidecar.seasons:
|
|
builder.add_season(_season_from_sidecar(season))
|
|
return builder.build()
|
|
|
|
|
|
def _season_from_sidecar(season: SeasonSidecar) -> Season:
|
|
sb = SeasonBuilder(season.number)
|
|
for ep in season.episodes:
|
|
sb.add_episode(_episode_from_sidecar(ep, season.number))
|
|
if not season.episodes:
|
|
# PACK mode — populate season-scoped tracks from the sidecar.
|
|
sb.set_audio_tracks(_synth_audio_tracks(season.audio_languages))
|
|
sb.set_subtitle_tracks(_synth_subtitle_tracks(season.subtitles))
|
|
return sb.build()
|
|
|
|
|
|
def _synth_audio_tracks(
|
|
languages: tuple[str, ...],
|
|
) -> tuple[AudioTrack, ...]:
|
|
return tuple(
|
|
AudioTrack(
|
|
index=i,
|
|
codec=None,
|
|
channels=None,
|
|
channel_layout=None,
|
|
language=lang,
|
|
)
|
|
for i, lang in enumerate(languages)
|
|
)
|
|
|
|
|
|
def _synth_subtitle_tracks(
|
|
entries: tuple[SubtitleEntry, ...],
|
|
) -> tuple[SubtitleTrack, ...]:
|
|
return tuple(
|
|
SubtitleTrack(
|
|
index=i,
|
|
codec=None,
|
|
language=entry.language,
|
|
is_default=False,
|
|
is_forced=(entry.type == "forced"),
|
|
)
|
|
for i, entry in enumerate(entries)
|
|
)
|
|
|
|
|
|
def _episode_from_sidecar(
|
|
episode: EpisodeSidecar, season_number: SeasonNumber
|
|
) -> Episode:
|
|
return Episode(
|
|
season_number=season_number,
|
|
episode_number=episode.number,
|
|
title="",
|
|
file_path=FilePath(episode.path),
|
|
audio_tracks=_synth_audio_tracks(episode.audio_languages),
|
|
subtitle_tracks=_synth_subtitle_tracks(episode.subtitles),
|
|
)
|
|
|
|
|
|
__all__ = ["from_sidecar", "to_sidecar"]
|