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:
@@ -379,7 +379,15 @@ class DotAlfredTVShowLibraryIndex:
|
|||||||
return self._library_root / INDEX_FILENAME
|
return self._library_root / INDEX_FILENAME
|
||||||
|
|
||||||
def _load_or_heal(self) -> TVShowLibraryIndexSidecar:
|
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
|
path = self._index_path
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -389,12 +397,18 @@ class DotAlfredTVShowLibraryIndex:
|
|||||||
return self.heal()
|
return self.heal()
|
||||||
try:
|
try:
|
||||||
raw = read_yaml(path)
|
raw = read_yaml(path)
|
||||||
return TVShowLibraryIndexSidecar.model_validate(raw)
|
sidecar = TVShowLibraryIndexSidecar.model_validate(raw)
|
||||||
except (SidecarSchemaError, ValidationError) as exc:
|
except (SidecarSchemaError, ValidationError) as exc:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"library index at %s is corrupt (%s) — healing", path, exc
|
"library index at %s is corrupt (%s) — healing", path, exc
|
||||||
)
|
)
|
||||||
return self.heal()
|
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:
|
def _build_from_releases(self) -> TVShowLibraryIndexSidecar:
|
||||||
"""Walk the per-show sidecars and synthesize an index.
|
"""Walk the per-show sidecars and synthesize an index.
|
||||||
@@ -544,6 +558,8 @@ class DotAlfredMovieLibraryIndex:
|
|||||||
return self._library_root / INDEX_FILENAME
|
return self._library_root / INDEX_FILENAME
|
||||||
|
|
||||||
def _load_or_heal(self) -> MovieLibraryIndexSidecar:
|
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
|
path = self._index_path
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -553,12 +569,18 @@ class DotAlfredMovieLibraryIndex:
|
|||||||
return self.heal()
|
return self.heal()
|
||||||
try:
|
try:
|
||||||
raw = read_yaml(path)
|
raw = read_yaml(path)
|
||||||
return MovieLibraryIndexSidecar.model_validate(raw)
|
sidecar = MovieLibraryIndexSidecar.model_validate(raw)
|
||||||
except (SidecarSchemaError, ValidationError) as exc:
|
except (SidecarSchemaError, ValidationError) as exc:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"library index at %s is corrupt (%s) — healing", path, exc
|
"library index at %s is corrupt (%s) — healing", path, exc
|
||||||
)
|
)
|
||||||
return self.heal()
|
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:
|
def _build_from_releases(self) -> MovieLibraryIndexSidecar:
|
||||||
now = datetime.now(UTC)
|
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:
|
def _dump_model(model) -> dict:
|
||||||
"""Serialize a Pydantic model to a YAML-friendly dict.
|
"""Serialize a Pydantic model to a YAML-friendly dict.
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,12 @@ class Settings(BaseSettings):
|
|||||||
qbittorrent_host_path: str | None = None
|
qbittorrent_host_path: str | None = None
|
||||||
qbittorrent_container_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 ---
|
# --- API KEYS ---
|
||||||
tmdb_api_key: str | None = None
|
tmdb_api_key: str | None = None
|
||||||
tmdb_base_url: str = "https://api.themoviedb.org/3"
|
tmdb_base_url: str = "https://api.themoviedb.org/3"
|
||||||
|
|||||||
@@ -264,3 +264,81 @@ class TestMovieLibraryIndex:
|
|||||||
# Placeholder until TMDB sync.
|
# Placeholder until TMDB sync.
|
||||||
assert entry.release_year is None
|
assert entry.release_year is None
|
||||||
assert any("healing" in r.message for r in caplog.records)
|
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
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user