From cc334a7951a2da79d87afdef3bb7e743fd9f0e15 Mon Sep 17 00:00:00 2001 From: Francwa Date: Mon, 25 May 2026 21:14:18 +0200 Subject: [PATCH] =?UTF-8?q?feat(dot=5Falfred/v2):=20Phase=204=20Step=204?= =?UTF-8?q?=20=E2=80=94=20settings=20+=20anchor=20warning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small additions that close out Phase 4's loose ends. Settings — tmdb_cache_ttl_days class Settings(BaseSettings): # --- DOT_ALFRED --- tmdb_cache_ttl_days: int = 14 Default 14 days, matching the dot_alfred_v2 master spec. Will drive the Phase 5 TTL policy on TVShowLibraryIndexSidecar / MovieLibraryIndexSidecar (decide when a TMDB-cached entry is stale and triggers a refresh sync). Anchor-mismatch warning DotAlfredTVShowLibraryIndex._load_or_heal and DotAlfredMovieLibraryIndex ._load_or_heal now cross-check each indexed entry's metadata.path against the on-disk folder layout right after a successful parse. Drift (sidecar says folder X, X no longer exists under library_root) is surfaced as a WARNING log — one per missing folder, with the tmdb_id for cross-reference. No auto-heal on drift; the caller decides (the heal path remains opt-in via index.heal()). The warning fires only on the parsed-index path. The heal path always synthesizes entries from real folder names, so it can never drift — silent by construction. Tests * TestTVShowLibraryIndexAnchorWarning — 3 scenarios: warn-on-drift / no-warn-on-match / no-warn-on-heal. * TestMovieLibraryIndexAnchorWarning — symmetric coverage. Full suite: 1237 passed / 8 skipped / 4 xfailed. --- .../persistence/dot_alfred/v2/repository.py | 53 ++++++++++++- alfred/settings.py | 6 ++ .../dot_alfred/v2/test_library_index.py | 78 +++++++++++++++++++ 3 files changed, 134 insertions(+), 3 deletions(-) diff --git a/alfred/infrastructure/persistence/dot_alfred/v2/repository.py b/alfred/infrastructure/persistence/dot_alfred/v2/repository.py index 8d64e63..9cf518f 100644 --- a/alfred/infrastructure/persistence/dot_alfred/v2/repository.py +++ b/alfred/infrastructure/persistence/dot_alfred/v2/repository.py @@ -379,7 +379,15 @@ class DotAlfredTVShowLibraryIndex: return self._library_root / INDEX_FILENAME def _load_or_heal(self) -> TVShowLibraryIndexSidecar: - """Return the parsed index, healing silently on missing/corrupt.""" + """Return the parsed index, healing silently on missing/corrupt. + + On a successful parse, cross-check each entry's + ``metadata.path`` against the on-disk folder layout and log a + warning per drift (sidecar says folder X, X no longer exists + under ``library_root``). No auto-heal on drift — the caller + decides (an explicit :meth:`heal` rebuilds from the per-show + sidecars). + """ path = self._index_path if not path.is_file(): logger.info( @@ -389,12 +397,18 @@ class DotAlfredTVShowLibraryIndex: return self.heal() try: raw = read_yaml(path) - return TVShowLibraryIndexSidecar.model_validate(raw) + sidecar = TVShowLibraryIndexSidecar.model_validate(raw) except (SidecarSchemaError, ValidationError) as exc: logger.warning( "library index at %s is corrupt (%s) — healing", path, exc ) return self.heal() + _warn_on_missing_paths( + self._library_root, + ((e.metadata.path, e.tmdb_id) for e in sidecar.shows), + kind="show", + ) + return sidecar def _build_from_releases(self) -> TVShowLibraryIndexSidecar: """Walk the per-show sidecars and synthesize an index. @@ -544,6 +558,8 @@ class DotAlfredMovieLibraryIndex: return self._library_root / INDEX_FILENAME def _load_or_heal(self) -> MovieLibraryIndexSidecar: + """See :meth:`DotAlfredTVShowLibraryIndex._load_or_heal` for the + anchor-mismatch warning policy — same shape applies here.""" path = self._index_path if not path.is_file(): logger.info( @@ -553,12 +569,18 @@ class DotAlfredMovieLibraryIndex: return self.heal() try: raw = read_yaml(path) - return MovieLibraryIndexSidecar.model_validate(raw) + sidecar = MovieLibraryIndexSidecar.model_validate(raw) except (SidecarSchemaError, ValidationError) as exc: logger.warning( "library index at %s is corrupt (%s) — healing", path, exc ) return self.heal() + _warn_on_missing_paths( + self._library_root, + ((e.metadata.path, e.tmdb_id) for e in sidecar.movies), + kind="movie", + ) + return sidecar def _build_from_releases(self) -> MovieLibraryIndexSidecar: now = datetime.now(UTC) @@ -591,6 +613,31 @@ class DotAlfredMovieLibraryIndex: # ════════════════════════════════════════════════════════════════════════════ +def _warn_on_missing_paths( + library_root: Path, + entries, + *, + kind: str, +) -> None: + """Log a warning for each index entry whose folder no longer exists. + + ``entries`` is an iterable of ``(path, tmdb_id)`` pairs (path + relative to ``library_root``). The check is read-only — drift is + surfaced but never auto-healed; callers decide whether to call + :meth:`heal` or correct the entry on the next ``upsert``. + """ + for path, tmdb_id in entries: + if not (library_root / path).is_dir(): + logger.warning( + "library index anchor mismatch (%s tmdb_id=%s): " + "folder %r referenced by index does not exist under %s", + kind, + tmdb_id, + path, + library_root, + ) + + def _dump_model(model) -> dict: """Serialize a Pydantic model to a YAML-friendly dict. diff --git a/alfred/settings.py b/alfred/settings.py index 7351a7f..45d2c97 100644 --- a/alfred/settings.py +++ b/alfred/settings.py @@ -56,6 +56,12 @@ class Settings(BaseSettings): qbittorrent_host_path: str | None = None qbittorrent_container_path: str | None = None + # --- DOT_ALFRED --- + # Number of days a TMDB-cached library-index entry is considered + # fresh before the next sync refreshes it. Drives the Phase 5 TTL + # policy on TVShowLibraryIndexSidecar / MovieLibraryIndexSidecar. + tmdb_cache_ttl_days: int = 14 + # --- API KEYS --- tmdb_api_key: str | None = None tmdb_base_url: str = "https://api.themoviedb.org/3" diff --git a/tests/infrastructure/persistence/dot_alfred/v2/test_library_index.py b/tests/infrastructure/persistence/dot_alfred/v2/test_library_index.py index 23a2fec..ed65c77 100644 --- a/tests/infrastructure/persistence/dot_alfred/v2/test_library_index.py +++ b/tests/infrastructure/persistence/dot_alfred/v2/test_library_index.py @@ -264,3 +264,81 @@ class TestMovieLibraryIndex: # Placeholder until TMDB sync. assert entry.release_year is None assert any("healing" in r.message for r in caplog.records) + + +# ════════════════════════════════════════════════════════════════════════════ +# Anchor-mismatch warnings (parsed-index path only — heal path is silent) +# ════════════════════════════════════════════════════════════════════════════ + + +class TestTVShowLibraryIndexAnchorWarning: + def test_warns_when_indexed_folder_missing( + self, tv_library, foundation_release, foundation_tmdb_info, now_utc, caplog + ): + # Seed the index with a path that exists ("Foundation"), then + # delete the folder so the next read detects the drift. + index = DotAlfredTVShowLibraryIndex(tv_library) + index.upsert( + foundation_tmdb_info, + foundation_release, + path="Foundation", + fetched_at=now_utc, + ) + (tv_library / "Foundation").rmdir() + with caplog.at_level(logging.WARNING): + index.find_by_tmdb_id(TmdbId(84958)) + assert any( + "anchor mismatch" in r.message and "Foundation" in r.message + for r in caplog.records + ) + + def test_no_warning_when_indexed_folder_present( + self, tv_library, foundation_release, foundation_tmdb_info, now_utc, caplog + ): + index = DotAlfredTVShowLibraryIndex(tv_library) + index.upsert( + foundation_tmdb_info, + foundation_release, + path="Foundation", + fetched_at=now_utc, + ) + with caplog.at_level(logging.WARNING): + index.find_by_tmdb_id(TmdbId(84958)) + assert not any( + "anchor mismatch" in r.message for r in caplog.records + ) + + def test_no_warning_on_heal_path( + self, tv_library, foundation_release, caplog + ): + # The heal path always synthesizes entries from real folder + # names, so it can never drift — no anchor warning should fire. + release_repo = DotAlfredSeriesReleaseRepository(tv_library) + release_repo.save(foundation_release, show_folder="Foundation") + index = DotAlfredTVShowLibraryIndex(tv_library, release_repo=release_repo) + with caplog.at_level(logging.WARNING): + index.find_by_tmdb_id(TmdbId(84958)) + assert not any( + "anchor mismatch" in r.message for r in caplog.records + ) + + +class TestMovieLibraryIndexAnchorWarning: + def test_warns_when_indexed_folder_missing( + self, movie_library, inception_release, now_utc, caplog + ): + index = DotAlfredMovieLibraryIndex(movie_library) + index.upsert( + inception_release, + name="Inception", + release_year=2010, + path=inception_release.folder, + fetched_at=now_utc, + ) + (movie_library / inception_release.folder).rmdir() + with caplog.at_level(logging.WARNING): + index.find_by_tmdb_id(TmdbId(27205)) + assert any( + "anchor mismatch" in r.message and inception_release.folder in r.message + for r in caplog.records + )