diff --git a/CHANGELOG.md b/CHANGELOG.md index 96bc6db..ace07cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,7 +41,7 @@ callers). season becomes `SeasonRelease(mode=PACK, folder=…, episodes=())`. The slot map stays empty until the Phase 5 TMDB sync supplies `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 Phase 5 TTL policy on library-index entries (`fetched_at + TTL` drives refresh decisions). @@ -52,6 +52,57 @@ callers). `WARNING` (one per missing folder, with `tmdb_id`); the heal path stays silent by construction (it always synthesizes from real 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 @@ -67,16 +118,18 @@ callers). - The two Phase 3 module-level test skips (`test_repository.py`, `test_serializer.py`) are lifted by 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 -- **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). -- The `MediaWithTracks` mixin in `alfred.domain.shared.media` is - orphaned after Phase 3 (no aggregate inherits it). **Parked for - Phase 5**, which will either mount it on `MovieRelease` / - `SeasonRelease` (both already carry `audio_tracks` + - `subtitle_tracks`) or remove it for good. +- Phase 5 cleanup sweep returns zero hits for `MediaWithTracks`, + v1 dot_alfred symbols, v1 sidecar names, and `alfred.application. + library` — the v2 surface is the only one left. ### Changed diff --git a/alfred/domain/shared/media.py b/alfred/domain/shared/media.py index 1497da7..e254d1c 100644 --- a/alfred/domain/shared/media.py +++ b/alfred/domain/shared/media.py @@ -13,15 +13,11 @@ from __future__ import annotations from dataclasses import dataclass, field -from .value_objects import Language - __all__ = [ "AudioTrack", "MediaInfo", - "MediaWithTracks", "SubtitleTrack", "VideoTrack", - "track_lang_matches", ] @@ -194,84 +190,3 @@ class MediaInfo: 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