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:
+60
-7
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
Reference in New Issue
Block a user