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.
This commit is contained in:
@@ -0,0 +1,26 @@
|
|||||||
|
"""Application-layer exceptions shared across orchestrators.
|
||||||
|
|
||||||
|
Kept in a dedicated module (rather than inside each orchestrator's
|
||||||
|
file) because the sync flows for TV shows and movies raise structurally
|
||||||
|
identical "not found in library" errors — pulling them out makes the
|
||||||
|
shared semantics explicit and avoids cross-imports between the
|
||||||
|
``tv_shows`` and ``movies`` packages.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
class ShowNotFoundInLibrary(LookupError):
|
||||||
|
"""Raised when no on-disk TV show carries the requested ``tmdb_id``.
|
||||||
|
|
||||||
|
The sync orchestrator raises this when both the library index and
|
||||||
|
the per-show release repository return ``None`` for a lookup —
|
||||||
|
there is nothing on disk to refresh TMDB facts against.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class MovieNotFoundInLibrary(LookupError):
|
||||||
|
"""Raised when no on-disk movie carries the requested ``tmdb_id``.
|
||||||
|
|
||||||
|
Symmetric to :class:`ShowNotFoundInLibrary` for the movies library.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
"""``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
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Shared fixtures for ``alfred.application.tv_shows`` tests.
|
||||||
|
|
||||||
|
These mirror the v2 dot_alfred conftest (tv_library + foundation_release
|
||||||
|
+ now_utc) so the sync orchestrator tests can compose against the same
|
||||||
|
realistic aggregate without cross-package fixture inheritance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from alfred.domain.releases.entities import (
|
||||||
|
EpisodeRelease,
|
||||||
|
SeasonRelease,
|
||||||
|
SeriesRelease,
|
||||||
|
TrackProfile,
|
||||||
|
)
|
||||||
|
from alfred.domain.releases.value_objects import EpisodeRange, ReleaseMode
|
||||||
|
from alfred.domain.shared.media import AudioTrack
|
||||||
|
from alfred.domain.shared.value_objects import FilePath, ImdbId, TmdbId
|
||||||
|
from alfred.domain.tv_shows.value_objects import EpisodeNumber, SeasonNumber
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tv_library(tmp_path):
|
||||||
|
"""Empty ``tv_shows/`` with a Foundation/ folder ready for sidecars."""
|
||||||
|
root = tmp_path / "tv_shows"
|
||||||
|
root.mkdir()
|
||||||
|
(root / "Foundation").mkdir()
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def foundation_release() -> SeriesRelease:
|
||||||
|
"""Minimal Foundation S01 PACK — enough for slot-map assertions."""
|
||||||
|
season = SeasonRelease(
|
||||||
|
season_number=SeasonNumber(1),
|
||||||
|
folder="Foundation.S01",
|
||||||
|
mode=ReleaseMode.PACK,
|
||||||
|
episodes=(
|
||||||
|
EpisodeRelease(
|
||||||
|
episodes=EpisodeRange(EpisodeNumber(1), EpisodeNumber(1)),
|
||||||
|
file_path=FilePath("Foundation.S01/Foundation.S01E01.mkv"),
|
||||||
|
tracks=TrackProfile(
|
||||||
|
audio_tracks=(
|
||||||
|
AudioTrack(
|
||||||
|
index=0,
|
||||||
|
codec="eac3",
|
||||||
|
channels=6,
|
||||||
|
channel_layout="5.1",
|
||||||
|
language="eng",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return SeriesRelease(
|
||||||
|
tmdb_id=TmdbId(84958),
|
||||||
|
imdb_id=ImdbId("tt0804484"),
|
||||||
|
seasons=(season,),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def now_utc() -> datetime:
|
||||||
|
"""Stable UTC reference for deterministic fetched_at fields."""
|
||||||
|
return datetime(2026, 5, 25, 8, 30, 0, tzinfo=UTC)
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
"""Tests for ``sync_show`` — TMDB sync orchestrator for TV shows.
|
||||||
|
|
||||||
|
Cover the four interesting paths the spec calls out:
|
||||||
|
|
||||||
|
* placeholder entry (auto-healed index) — always refreshes regardless of TTL;
|
||||||
|
* fresh entry within TTL — no-op, no TMDB call;
|
||||||
|
* stale entry past TTL — refreshes;
|
||||||
|
* ``force=True`` — refreshes regardless of TTL;
|
||||||
|
* missing on-disk sidecar but indexed — warns + upserts empty slots;
|
||||||
|
* not in index and no sidecar — raises :class:`ShowNotFoundInLibrary`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from alfred.application.exceptions import ShowNotFoundInLibrary
|
||||||
|
from alfred.application.tv_shows.sync import sync_show
|
||||||
|
from alfred.domain.shared.value_objects import TmdbId
|
||||||
|
from alfred.infrastructure.api.tmdb.dto import TmdbSeasonInfo, TmdbShowInfo
|
||||||
|
from alfred.infrastructure.persistence.dot_alfred.v2.repository import (
|
||||||
|
DotAlfredSeriesReleaseRepository,
|
||||||
|
DotAlfredTVShowLibraryIndex,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _StubTMDBClient:
|
||||||
|
"""Minimal stand-in implementing only ``get_tv_show_info``.
|
||||||
|
|
||||||
|
Records call count so tests can assert no-op behavior on fresh
|
||||||
|
entries. Raising stubs are passed inline when needed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, info: TmdbShowInfo) -> None:
|
||||||
|
self._info = info
|
||||||
|
self.calls: list[int] = []
|
||||||
|
|
||||||
|
def get_tv_show_info(self, tmdb_id: int) -> TmdbShowInfo:
|
||||||
|
self.calls.append(tmdb_id)
|
||||||
|
return self._info
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tmdb_info_fresh() -> TmdbShowInfo:
|
||||||
|
"""TMDB payload returned by the stub — distinct from any placeholder."""
|
||||||
|
return TmdbShowInfo(
|
||||||
|
tmdb_id=84958,
|
||||||
|
imdb_id="tt0804484",
|
||||||
|
name="Foundation",
|
||||||
|
status="Returning Series",
|
||||||
|
seasons=(
|
||||||
|
TmdbSeasonInfo(number=1, episode_count=10, aired=True),
|
||||||
|
TmdbSeasonInfo(number=2, episode_count=10, aired=True),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def now_clock(now_utc):
|
||||||
|
"""A ``now()`` callable that returns the fixed ``now_utc``."""
|
||||||
|
return lambda: now_utc
|
||||||
|
|
||||||
|
|
||||||
|
def _make_index_and_repos(tv_library):
|
||||||
|
repo = DotAlfredSeriesReleaseRepository(tv_library)
|
||||||
|
index = DotAlfredTVShowLibraryIndex(tv_library, release_repo=repo)
|
||||||
|
return repo, index
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Happy paths
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestSyncShowRefreshesPlaceholder:
|
||||||
|
def test_placeholder_entry_refreshes_regardless_of_ttl(
|
||||||
|
self, tv_library, foundation_release, tmdb_info_fresh, now_clock
|
||||||
|
):
|
||||||
|
# Persist the per-show sidecar; the index auto-heal will then
|
||||||
|
# produce a placeholder entry (status="unknown") on first read.
|
||||||
|
repo, index = _make_index_and_repos(tv_library)
|
||||||
|
repo.save(foundation_release, show_folder="Foundation")
|
||||||
|
client = _StubTMDBClient(tmdb_info_fresh)
|
||||||
|
|
||||||
|
# TTL=365 days — without the placeholder rule, this entry would
|
||||||
|
# be considered fresh (auto-heal stamps fetched_at=now).
|
||||||
|
result = sync_show(
|
||||||
|
tv_library,
|
||||||
|
tmdb_id=TmdbId(84958),
|
||||||
|
index=index,
|
||||||
|
release_repo=repo,
|
||||||
|
tmdb_client=client,
|
||||||
|
ttl_days=365,
|
||||||
|
now=now_clock,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert client.calls == [84958]
|
||||||
|
assert result.name == "Foundation"
|
||||||
|
assert result.status == "Returning Series"
|
||||||
|
# Index entry was upserted with the on-disk slot map.
|
||||||
|
assert result.seasons[0].episodes # season 1 has files
|
||||||
|
|
||||||
|
|
||||||
|
class TestSyncShowTTLGate:
|
||||||
|
def test_fresh_entry_within_ttl_is_noop(
|
||||||
|
self, tv_library, foundation_release, tmdb_info_fresh, now_utc
|
||||||
|
):
|
||||||
|
repo, index = _make_index_and_repos(tv_library)
|
||||||
|
repo.save(foundation_release, show_folder="Foundation")
|
||||||
|
# First sync — populates the entry with real status.
|
||||||
|
client = _StubTMDBClient(tmdb_info_fresh)
|
||||||
|
sync_show(
|
||||||
|
tv_library,
|
||||||
|
tmdb_id=TmdbId(84958),
|
||||||
|
index=index,
|
||||||
|
release_repo=repo,
|
||||||
|
tmdb_client=client,
|
||||||
|
ttl_days=14,
|
||||||
|
now=lambda: now_utc,
|
||||||
|
)
|
||||||
|
first_count = len(client.calls)
|
||||||
|
|
||||||
|
# Second sync, ttl=14d, called 3 days later — should be no-op.
|
||||||
|
later = now_utc + timedelta(days=3)
|
||||||
|
result = sync_show(
|
||||||
|
tv_library,
|
||||||
|
tmdb_id=TmdbId(84958),
|
||||||
|
index=index,
|
||||||
|
release_repo=repo,
|
||||||
|
tmdb_client=client,
|
||||||
|
ttl_days=14,
|
||||||
|
now=lambda: later,
|
||||||
|
)
|
||||||
|
assert len(client.calls) == first_count # no extra call
|
||||||
|
assert result.status == "Returning Series"
|
||||||
|
|
||||||
|
def test_stale_entry_past_ttl_refreshes(
|
||||||
|
self, tv_library, foundation_release, tmdb_info_fresh, now_utc
|
||||||
|
):
|
||||||
|
repo, index = _make_index_and_repos(tv_library)
|
||||||
|
repo.save(foundation_release, show_folder="Foundation")
|
||||||
|
client = _StubTMDBClient(tmdb_info_fresh)
|
||||||
|
sync_show(
|
||||||
|
tv_library,
|
||||||
|
tmdb_id=TmdbId(84958),
|
||||||
|
index=index,
|
||||||
|
release_repo=repo,
|
||||||
|
tmdb_client=client,
|
||||||
|
ttl_days=14,
|
||||||
|
now=lambda: now_utc,
|
||||||
|
)
|
||||||
|
first_count = len(client.calls)
|
||||||
|
|
||||||
|
# 20 days later — past the 14d TTL.
|
||||||
|
later = now_utc + timedelta(days=20)
|
||||||
|
sync_show(
|
||||||
|
tv_library,
|
||||||
|
tmdb_id=TmdbId(84958),
|
||||||
|
index=index,
|
||||||
|
release_repo=repo,
|
||||||
|
tmdb_client=client,
|
||||||
|
ttl_days=14,
|
||||||
|
now=lambda: later,
|
||||||
|
)
|
||||||
|
assert len(client.calls) == first_count + 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestSyncShowForce:
|
||||||
|
def test_force_bypasses_ttl(
|
||||||
|
self, tv_library, foundation_release, tmdb_info_fresh, now_utc
|
||||||
|
):
|
||||||
|
repo, index = _make_index_and_repos(tv_library)
|
||||||
|
repo.save(foundation_release, show_folder="Foundation")
|
||||||
|
client = _StubTMDBClient(tmdb_info_fresh)
|
||||||
|
sync_show(
|
||||||
|
tv_library,
|
||||||
|
tmdb_id=TmdbId(84958),
|
||||||
|
index=index,
|
||||||
|
release_repo=repo,
|
||||||
|
tmdb_client=client,
|
||||||
|
ttl_days=14,
|
||||||
|
now=lambda: now_utc,
|
||||||
|
)
|
||||||
|
first_count = len(client.calls)
|
||||||
|
|
||||||
|
# Same clock instant — would normally be a no-op.
|
||||||
|
sync_show(
|
||||||
|
tv_library,
|
||||||
|
tmdb_id=TmdbId(84958),
|
||||||
|
index=index,
|
||||||
|
release_repo=repo,
|
||||||
|
tmdb_client=client,
|
||||||
|
ttl_days=14,
|
||||||
|
now=lambda: now_utc,
|
||||||
|
force=True,
|
||||||
|
)
|
||||||
|
assert len(client.calls) == first_count + 1
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Degenerate paths
|
||||||
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestSyncShowMissingSidecar:
|
||||||
|
def test_indexed_show_without_sidecar_still_upserts_with_empty_slots(
|
||||||
|
self, tv_library, foundation_release, tmdb_info_fresh, now_utc, caplog
|
||||||
|
):
|
||||||
|
# First, fully sync — establishes an index entry pointing at
|
||||||
|
# the Foundation folder.
|
||||||
|
repo, index = _make_index_and_repos(tv_library)
|
||||||
|
repo.save(foundation_release, show_folder="Foundation")
|
||||||
|
client = _StubTMDBClient(tmdb_info_fresh)
|
||||||
|
sync_show(
|
||||||
|
tv_library,
|
||||||
|
tmdb_id=TmdbId(84958),
|
||||||
|
index=index,
|
||||||
|
release_repo=repo,
|
||||||
|
tmdb_client=client,
|
||||||
|
ttl_days=14,
|
||||||
|
now=lambda: now_utc,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove the per-show sidecar; index entry persists.
|
||||||
|
(tv_library / "Foundation" / ".alfred").unlink()
|
||||||
|
|
||||||
|
# Force a re-sync — load_by_tmdb_id will return None, but the
|
||||||
|
# index entry's path anchor lets us proceed with empty slots.
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
result = sync_show(
|
||||||
|
tv_library,
|
||||||
|
tmdb_id=TmdbId(84958),
|
||||||
|
index=index,
|
||||||
|
release_repo=repo,
|
||||||
|
tmdb_client=client,
|
||||||
|
ttl_days=14,
|
||||||
|
now=lambda: now_utc,
|
||||||
|
force=True,
|
||||||
|
)
|
||||||
|
assert result.metadata.path == "Foundation"
|
||||||
|
# Slot map is empty across all seasons.
|
||||||
|
for season in result.seasons:
|
||||||
|
assert season.episodes == {}
|
||||||
|
assert any("per-show sidecar missing" in r.message for r in caplog.records)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSyncShowNotFound:
|
||||||
|
def test_no_index_entry_no_sidecar_raises(
|
||||||
|
self, tv_library, tmdb_info_fresh, now_clock
|
||||||
|
):
|
||||||
|
repo, index = _make_index_and_repos(tv_library)
|
||||||
|
# No per-show sidecar saved → index auto-heals to an empty
|
||||||
|
# entry list. tmdb_id=84958 is not in the (empty) library.
|
||||||
|
client = _StubTMDBClient(tmdb_info_fresh)
|
||||||
|
with pytest.raises(ShowNotFoundInLibrary, match="84958"):
|
||||||
|
sync_show(
|
||||||
|
tv_library,
|
||||||
|
tmdb_id=TmdbId(84958),
|
||||||
|
index=index,
|
||||||
|
release_repo=repo,
|
||||||
|
tmdb_client=client,
|
||||||
|
ttl_days=14,
|
||||||
|
now=now_clock,
|
||||||
|
)
|
||||||
|
# Note: TMDB *was* called (sync hits TMDB then checks for the
|
||||||
|
# release) — that's by design, so the caller learns whether
|
||||||
|
# tmdb_id is even valid before being told "not in library".
|
||||||
|
assert client.calls == [84958]
|
||||||
Reference in New Issue
Block a user