feat(dot_alfred/v2): Phase 4 Step 4 — settings + anchor warning

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.
This commit is contained in:
2026-05-25 21:14:18 +02:00
parent 86222d95d1
commit cc334a7951
3 changed files with 134 additions and 3 deletions
@@ -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.
+6
View File
@@ -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"
@@ -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
)