chore(dot_alfred): Phase 5 cleanup + changelog

Delete the orphan MediaWithTracks mixin (and its only consumer,
track_lang_matches) from alfred.domain.shared.media — zero callers
since the v2 aggregates landed in Phase 3, parked for the Phase 5
decision in CHANGELOG.

Cleanup sweep across alfred/ and tests/ returns zero hits for:
* MediaWithTracks
* the v1 dot_alfred symbols
* the v1 sidecar names
* the alfred.application.library package
The v2 surface is the only one left.

CHANGELOG updated with:
* the Phase 5 sync orchestrators (sync_show / sync_movie),
* the Phase 4b PACK vs EPISODIC fix (Fixed section),
* the MediaWithTracks deletion in Removed,
* refreshed suite count (1277 passing).
This commit is contained in:
2026-05-26 00:55:17 +02:00
parent 7ff2e6bc4e
commit b3abad4da4
2 changed files with 60 additions and 92 deletions
+60 -7
View File
@@ -41,7 +41,7 @@ callers).
season becomes `SeasonRelease(mode=PACK, folder=…, episodes=())`. season becomes `SeasonRelease(mode=PACK, folder=…, episodes=())`.
The slot map stays empty until the Phase 5 TMDB sync supplies The slot map stays empty until the Phase 5 TMDB sync supplies
`episode_count` — no fabricated `EpisodeRange` lands in the `episode_count` — no fabricated `EpisodeRange` lands in the
sidecar. sidecar. *(Superseded by Phase 4b — see Fixed.)*
- **`Settings.tmdb_cache_ttl_days: int = 14`** — placeholder for the - **`Settings.tmdb_cache_ttl_days: int = 14`** — placeholder for the
Phase 5 TTL policy on library-index entries (`fetched_at + TTL` Phase 5 TTL policy on library-index entries (`fetched_at + TTL`
drives refresh decisions). drives refresh decisions).
@@ -52,6 +52,57 @@ callers).
`WARNING` (one per missing folder, with `tmdb_id`); the heal path `WARNING` (one per missing folder, with `tmdb_id`); the heal path
stays silent by construction (it always synthesizes from real stays silent by construction (it always synthesizes from real
folder names). folder names).
- **`.alfred` v2 — Phase 5: TMDB sync orchestrators.** Fifth phase
of `specs/dot_alfred_v2.md` on branch `refactor/dot-alfred-v2`.
Two new orchestrators refresh the library-root index's
TMDB-cached fields from on-disk truth + a single TMDB call:
- **`sync_show`** (`alfred/application/tv_shows/sync.py`) calls
`TMDBClient.get_tv_show_info`, loads the release via
`DotAlfredSeriesReleaseRepository.load_by_tmdb_id`, and upserts
the result into `DotAlfredTVShowLibraryIndex`. Honors
`Settings.tmdb_cache_ttl_days`; placeholder entries (auto-healed,
`status == "unknown"`) always refresh; `force=True` overrides
both gates. Raises `ShowNotFoundInLibrary` when neither index nor
sidecar carry `tmdb_id`. Indexed shows with a missing per-show
sidecar still get a fresh TMDB pass — slot map clears until
rescan repopulates it.
- **`sync_movie`** (`alfred/application/movies/sync.py`) is the
movie-side parallel. Placeholder signature is `name ==
metadata.path` (auto-heal copies the folder name into `name`;
the sidecar schema requires `name` non-empty so we can't use
`name == ""`). When the per-movie sidecar is gone but the
index entry remains, sync warns and returns the existing entry
unchanged (no upsert possible without a release).
- **`TmdbMovieInfo` DTO + `TMDBClient.get_movie_info`** — symmetric
to the existing `TmdbShowInfo` / `get_tv_show_info` pair. Carries
`tmdb_id`, `imdb_id`, `title`, and `release_year` (parsed from
TMDB's `release_date`).
- **`load_by_tmdb_id` on the v2 release repositories.** The series
repo returns `(SeriesRelease, show_folder_name)` so the sync
orchestrator can feed `DotAlfredTVShowLibraryIndex.upsert(...,
path=...)`; the movie repo returns `MovieRelease` alone (folder is
on `release.folder` already) and is provided as a semantic alias
of `find_by_tmdb_id` for symmetry.
- **`alfred/application/exceptions.py`** — new module for the two
shared `*NotFoundInLibrary` exceptions raised by the sync
orchestrators (`ShowNotFoundInLibrary`, `MovieNotFoundInLibrary`).
### Fixed
- **PACK vs EPISODIC classification (Phase 4b).** The Phase 4
walker + `rescan_show` logic classified seasons by parser output
(does the filename carry `Exx`?), but PACK vs EPISODIC is a
*structural* distinction:
- **PACK** = season folder with N flat `SxxEyy` videos.
- **EPISODIC** = season folder with N subfolders, each holding
one video.
The walker now descends two levels under `show_root` and
classifies per season folder. Mixed (flat + subfolders) is
malformed — warn and skip. `rescan_show` trusts the walker's
mode and stops conflating "single un-numbered video" with PACK
(that case is now skipped as malformed too). Tests rewritten
against the real model. Supersedes the PACK-semantics bullet
above in Added.
### Removed ### Removed
@@ -67,16 +118,18 @@ callers).
- The two Phase 3 module-level test skips - The two Phase 3 module-level test skips
(`test_repository.py`, `test_serializer.py`) are lifted by (`test_repository.py`, `test_serializer.py`) are lifted by
deleting the quarantined files. deleting the quarantined files.
- **`MediaWithTracks` mixin + `track_lang_matches` helper** in
`alfred.domain.shared.media`. Parked in Phase 4 pending a
Phase 5 decision; zero callers across `alfred/` and `tests/`
after the v2 aggregates landed, so both go.
### Internal ### Internal
- **Suite**: 1233 → 1237 passing; 10 → 8 skips (only LLM-not-running - **Suite**: 1233 → 1277 passing; 10 → 8 skips (only LLM-not-running
skips remain — the Phase 3 quarantines are gone with their files). skips remain — the Phase 3 quarantines are gone with their files).
- The `MediaWithTracks` mixin in `alfred.domain.shared.media` is - Phase 5 cleanup sweep returns zero hits for `MediaWithTracks`,
orphaned after Phase 3 (no aggregate inherits it). **Parked for v1 dot_alfred symbols, v1 sidecar names, and `alfred.application.
Phase 5**, which will either mount it on `MovieRelease` / library` — the v2 surface is the only one left.
`SeasonRelease` (both already carry `audio_tracks` +
`subtitle_tracks`) or remove it for good.
### Changed ### Changed
-85
View File
@@ -13,15 +13,11 @@ from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from .value_objects import Language
__all__ = [ __all__ = [
"AudioTrack", "AudioTrack",
"MediaInfo", "MediaInfo",
"MediaWithTracks",
"SubtitleTrack", "SubtitleTrack",
"VideoTrack", "VideoTrack",
"track_lang_matches",
] ]
@@ -194,84 +190,3 @@ class MediaInfo:
return len(self.audio_languages) > 1 return len(self.audio_languages) > 1
# ─────────────────────────────────────────────────────────────────────────────
# Language matching — shared helper + mixin
# ─────────────────────────────────────────────────────────────────────────────
def track_lang_matches(track_lang: str | None, query: str | Language) -> bool:
"""
Match a track's language string against a query (contract "C+").
* ``Language`` query → matches if the track string is any known
representation of that Language (delegates to ``Language.matches``).
Powerful, cross-format mode.
* ``str`` query → case-insensitive direct comparison against
``track_lang``. Simple, no normalization, no registry lookup.
Callers needing cross-format resolution (``"fr"`` ↔ ``"fre"`` ↔
``"french"``) should resolve their string through a ``LanguageRegistry``
once and pass the resulting ``Language``.
"""
if track_lang is None:
return False
if isinstance(query, Language):
return query.matches(track_lang)
if isinstance(query, str):
return track_lang.lower().strip() == query.lower().strip()
return False
class MediaWithTracks:
"""
Mixin providing audio/subtitle helpers for entities with track collections.
Hosts must expose two attributes:
* ``audio_tracks: tuple[AudioTrack, ...]``
* ``subtitle_tracks: tuple[SubtitleTrack, ...]``
The helpers follow the "C+" matching contract: pass a :class:`Language`
for cross-format matching, or a ``str`` for case-insensitive comparison.
"""
# These attributes are provided by the host entity (Movie, Episode, …).
# Declared here only for type-checkers and to make the contract explicit.
audio_tracks: tuple[AudioTrack, ...]
subtitle_tracks: tuple[SubtitleTrack, ...]
# ── Audio helpers ──────────────────────────────────────────────────────
def has_audio_in(self, lang: str | Language) -> bool:
"""True if at least one audio track is in the given language."""
return any(track_lang_matches(t.language, lang) for t in self.audio_tracks)
def audio_languages(self) -> list[str]:
"""Unique audio languages across all tracks, in track order."""
seen: set[str] = set()
result: list[str] = []
for t in self.audio_tracks:
if t.language and t.language not in seen:
seen.add(t.language)
result.append(t.language)
return result
# ── Subtitle helpers ───────────────────────────────────────────────────
def has_subtitles_in(self, lang: str | Language) -> bool:
"""True if at least one subtitle track is in the given language."""
return any(track_lang_matches(t.language, lang) for t in self.subtitle_tracks)
def has_forced_subs(self) -> bool:
"""True if at least one subtitle track is flagged as forced."""
return any(t.is_forced for t in self.subtitle_tracks)
def subtitle_languages(self) -> list[str]:
"""Unique subtitle languages across all tracks, in track order."""
seen: set[str] = set()
result: list[str] = []
for t in self.subtitle_tracks:
if t.language and t.language not in seen:
seen.add(t.language)
result.append(t.language)
return result