Files
alfred/alfred/infrastructure/persistence/dot_alfred/bridge.py
T
francwa de7030fa9c feat(library): add rescan_show orchestrator + walker (Step 4)
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.
2026-05-24 15:22:18 +02:00

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"]