Files
alfred/alfred/application/tv_shows/sync.py
T
francwa 8f31f880aa feat(tv_shows): sync_show populates library index from TMDB
New orchestrator alfred.application.tv_shows.sync.sync_show calls
TMDBClient.get_tv_show_info, combines the response with the on-disk
release loaded via DotAlfredSeriesReleaseRepository.load_by_tmdb_id,
and upserts the result into DotAlfredTVShowLibraryIndex.

Policy:
* placeholders (auto-healed entries, status=="unknown") always
  refresh regardless of TTL,
* fresh entries within Settings.tmdb_cache_ttl_days are no-ops,
* stale entries past TTL refresh,
* force=True overrides both gates,
* indexed shows whose per-show sidecar is gone still get a fresh
  TMDB pass — slot map clears until rescan repopulates it,
* truly absent shows raise ShowNotFoundInLibrary from the new
  alfred.application.exceptions module.
2026-05-26 00:49:00 +02:00

147 lines
5.3 KiB
Python

"""``sync_show`` — refresh TMDB-cached fields on the TV library index.
The orchestrator hits TMDB for one show, combines the response with
the on-disk release (if any), and upserts the result into the
library-root index. It is the only place that calls
:meth:`TMDBClient.get_tv_show_info`; the rescan flow stays
TMDB-free.
TTL & placeholder policy
------------------------
``ttl_days`` (passed by the caller, sourced from
:attr:`Settings.tmdb_cache_ttl_days`) gates refreshes for entries
that already carry real TMDB facts. Placeholder entries — those
produced by the library index's auto-heal path, recognizable by
``status == "unknown"`` — always refresh regardless of TTL, because
auto-heal leaves the cache empty on purpose. ``force=True`` overrides
both gates.
Missing on-disk release
-----------------------
If the library index has an entry for ``tmdb_id`` but the per-show
sidecar is absent or corrupt, the sync still proceeds: ``release`` is
passed as ``None`` to :meth:`DotAlfredTVShowLibraryIndex.upsert`,
which produces an entry with TMDB facts but an empty episode-slot
map. Callers can then run :func:`rescan_show` to repopulate the slot
map. This is the "library knows the show, files temporarily missing"
state — a stale index pointing at a deleted folder.
If both index and release sidecar are missing, the show genuinely
isn't in the library and we raise
:class:`ShowNotFoundInLibrary` — sync cannot invent a folder anchor.
"""
from __future__ import annotations
import logging
from collections.abc import Callable
from datetime import UTC, datetime
from pathlib import Path
from alfred.application.exceptions import ShowNotFoundInLibrary
from alfred.domain.shared.value_objects import TmdbId
from alfred.infrastructure.api.tmdb.client import TMDBClient
from alfred.infrastructure.persistence.dot_alfred.v2.repository import (
DotAlfredSeriesReleaseRepository,
DotAlfredTVShowLibraryIndex,
)
from alfred.infrastructure.persistence.dot_alfred.v2.sidecar_root import (
ShowIndexEntry,
)
_LOG = logging.getLogger(__name__)
# Placeholder marker written by the library index's auto-heal path.
# See ``DotAlfredTVShowLibraryIndex._build_from_releases``.
_PLACEHOLDER_STATUS = "unknown"
def sync_show(
library_root: Path,
*,
tmdb_id: TmdbId,
index: DotAlfredTVShowLibraryIndex,
release_repo: DotAlfredSeriesReleaseRepository,
tmdb_client: TMDBClient,
ttl_days: int,
now: Callable[[], datetime] = lambda: datetime.now(UTC),
force: bool = False,
) -> ShowIndexEntry:
"""Refresh TMDB-cached fields for ``tmdb_id`` on the TV index.
Args:
library_root: TV shows library root (informational — the
index and repos already carry their own root).
tmdb_id: show identifier.
index: library-root index to read and upsert into.
release_repo: per-show sidecar repository, used to resolve
the on-disk release + folder anchor.
tmdb_client: TMDB HTTP client.
ttl_days: max age (days) for an already-synced entry before
it is considered stale. Sourced from
:attr:`Settings.tmdb_cache_ttl_days` by the caller.
now: clock injection for deterministic tests.
force: bypass TTL gate; placeholders always refresh
regardless of this flag.
Returns:
The fresh :class:`ShowIndexEntry` (the one already in the
index when fresh, the newly-upserted one otherwise).
Raises:
ShowNotFoundInLibrary: when neither index nor release repo
knows the show.
TMDBAPIError: re-raised from the client.
"""
del library_root # not needed for the algorithm; documented for symmetry with rescan_show
current_time = now()
existing = index.find_by_tmdb_id(tmdb_id)
if existing is not None and not force and not _needs_refresh(
existing, ttl_days=ttl_days, now=current_time
):
return existing
info = tmdb_client.get_tv_show_info(tmdb_id.value)
loaded = release_repo.load_by_tmdb_id(tmdb_id)
if loaded is None and existing is None:
raise ShowNotFoundInLibrary(
f"no on-disk TV show carries tmdb_id={tmdb_id.value}"
)
if loaded is not None:
release, folder = loaded
else:
# Index entry exists but per-show sidecar is gone or corrupt.
# Use the anchor recorded in the index; the slot map will be
# empty until a rescan repopulates it.
release = None
folder = existing.metadata.path
_LOG.warning(
"sync_show: per-show sidecar missing for tmdb_id=%s; "
"upserting index entry with empty episode slots (anchor=%s)",
tmdb_id.value,
folder,
)
index.upsert(info, release, path=folder, fetched_at=current_time)
refreshed = index.find_by_tmdb_id(tmdb_id)
# ``upsert`` writes synchronously; the follow-up read returns the
# entry we just persisted. Defensive ``assert``: if it isn't there,
# the index implementation has regressed.
assert refreshed is not None, "upsert did not persist entry"
return refreshed
def _needs_refresh(
entry: ShowIndexEntry, *, ttl_days: int, now: datetime
) -> bool:
"""True if ``entry`` is a placeholder or older than ``ttl_days``."""
if entry.status == _PLACEHOLDER_STATUS:
return True
age = now - entry.metadata.fetched_at
return age.days >= ttl_days