8f31f880aa
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.
147 lines
5.3 KiB
Python
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
|