The correction shipped in the previous commit fired zero times on the live library. It compared Plex's addedAt against MediaShelf's first completed Tautulli play, but Tautulli history only starts 2025-03-08 and the wrong dates are overwhelmingly older than that. The evidence that proved the bug in the first place was Plex's own lastViewedAt - which the provider parsed and then dropped on the floor. Now stored as provider_last_viewed_at and folded into the bound: added_at = MIN(provider_added_at, first_watched_at, provider_last_viewed_at). Seasons also get their own provider_added_at (MIN over episodes), without which their added_at_source could never be computed. Fixture gains the case that actually failed: added last week, last viewed 1500 days ago, no Tautulli history at all - so only lastViewedAt carries it. Also tightened test_correction_leaves_unwatched_items_alone, which defined "unwatched" as watch_count=0 and so wrongly expected an item with Plex view evidence to be left alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GVbG48GAXfCZatcmX123Ra
782 lines
37 KiB
Python
782 lines
37 KiB
Python
"""Scan orchestration (§4.6).
|
|
|
|
Pulls libraries, items and history; normalizes; upserts inside one transaction
|
|
per library; rolls episodes up to seasons; resolves keep marks; marks vanished
|
|
items missing.
|
|
|
|
The property that matters most here is idempotency. A scanner that double-counts
|
|
sizes or duplicates history events produces a report that looks entirely
|
|
plausible and is wrong, which is worse than one that crashes.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import time
|
|
from dataclasses import dataclass
|
|
|
|
from . import keeps
|
|
from .db import Database
|
|
from .providers.base import (
|
|
Account,
|
|
AuthError,
|
|
Coverage,
|
|
HistoryProvider,
|
|
Item,
|
|
Library,
|
|
MediaProvider,
|
|
ProviderError,
|
|
WatchEvent,
|
|
)
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
class ScanBusy(RuntimeError):
|
|
pass
|
|
|
|
|
|
@dataclass
|
|
class ScanResult:
|
|
scan_id: int
|
|
status: str
|
|
items_seen: int = 0
|
|
items_added: int = 0
|
|
items_updated: int = 0
|
|
items_missing: int = 0
|
|
events_added: int = 0
|
|
warnings: list[str] = None
|
|
error: str | None = None
|
|
|
|
|
|
class Ingest:
|
|
def __init__(self, db: Database, cfg, media: MediaProvider,
|
|
history: HistoryProvider | None):
|
|
self.db = db
|
|
self.cfg = cfg
|
|
self.media = media
|
|
self.history = history
|
|
self.warnings: list[str] = []
|
|
|
|
# ── locking ──────────────────────────────────────────────────────────
|
|
|
|
def _acquire_lock(self, scan_id: int) -> None:
|
|
now = int(time.time())
|
|
row = self.db.one("SELECT * FROM scan_lock WHERE id = 1")
|
|
if row and row["scan_id"] is not None:
|
|
age = now - (row["acquired_at"] or 0)
|
|
if age < self.cfg.scan_lock_timeout_s:
|
|
raise ScanBusy("a scan is already running (started %ds ago)" % age)
|
|
# Stale lock: the previous scan died. Mark it failed and take over.
|
|
log.warning("breaking stale scan lock held by scan %s", row["scan_id"])
|
|
self.db.execute(
|
|
"UPDATE scan SET status='failed', finished_at=?, "
|
|
"error='abandoned - lock timed out' WHERE id=? AND status='running'",
|
|
(now, row["scan_id"]),
|
|
)
|
|
self.db.execute(
|
|
"INSERT INTO scan_lock (id, scan_id, holder, acquired_at) VALUES (1,?,?,?) "
|
|
"ON CONFLICT(id) DO UPDATE SET scan_id=excluded.scan_id, "
|
|
"holder=excluded.holder, acquired_at=excluded.acquired_at",
|
|
(scan_id, "%s:%s" % (os.uname().nodename, os.getpid()), now),
|
|
)
|
|
|
|
def _release_lock(self) -> None:
|
|
self.db.execute("UPDATE scan_lock SET scan_id=NULL, holder=NULL WHERE id=1")
|
|
|
|
def _progress(self, scan_id: int, text: str) -> None:
|
|
self.db.execute("UPDATE scan SET progress=? WHERE id=?", (text, scan_id))
|
|
|
|
def _warn(self, msg: str) -> None:
|
|
log.warning("scan warning: %s", msg)
|
|
if len(self.warnings) < 200:
|
|
self.warnings.append(msg)
|
|
|
|
# ── entry point ──────────────────────────────────────────────────────
|
|
|
|
def run(self, mode: str = "full", trigger: str = "manual") -> ScanResult:
|
|
now = int(time.time())
|
|
cur = self.db.execute(
|
|
"INSERT INTO scan (mode, trigger, status, started_at, history_source) "
|
|
"VALUES (?,?,'running',?,?)",
|
|
(mode, trigger, now, self.history.name if self.history else None),
|
|
)
|
|
scan_id = cur.lastrowid
|
|
try:
|
|
self._acquire_lock(scan_id)
|
|
except ScanBusy:
|
|
self.db.execute(
|
|
"UPDATE scan SET status='failed', finished_at=?, error=? WHERE id=?",
|
|
(now, "another scan is already running", scan_id),
|
|
)
|
|
raise
|
|
|
|
result = ScanResult(scan_id=scan_id, status="running", warnings=[])
|
|
try:
|
|
self._run_inner(scan_id, mode, result)
|
|
result.status = "succeeded"
|
|
except AuthError as e:
|
|
result.status, result.error = "failed", str(e)
|
|
log.error("scan %s failed on auth: %s", scan_id, e)
|
|
except Exception as e: # noqa: BLE001
|
|
result.status, result.error = "failed", str(e)
|
|
log.exception("scan %s failed", scan_id)
|
|
finally:
|
|
result.warnings = self.warnings
|
|
self.db.execute(
|
|
"UPDATE scan SET status=?, finished_at=?, items_seen=?, items_added=?, "
|
|
"items_updated=?, items_missing=?, events_added=?, warning_count=?, "
|
|
"warnings=?, error=?, progress=NULL WHERE id=?",
|
|
(result.status, int(time.time()), result.items_seen, result.items_added,
|
|
result.items_updated, result.items_missing, result.events_added,
|
|
len(self.warnings), json.dumps(self.warnings[:200]), result.error, scan_id),
|
|
)
|
|
self._release_lock()
|
|
return result
|
|
|
|
def _run_inner(self, scan_id: int, mode: str, result: ScanResult) -> None:
|
|
self._progress(scan_id, "connecting")
|
|
info = self.media.server_info()
|
|
provider_id = self._upsert_provider(info)
|
|
self.db.execute("UPDATE scan SET provider_id=? WHERE id=?", (provider_id, scan_id))
|
|
|
|
self._check_history_pairing(provider_id, info)
|
|
|
|
self._progress(scan_id, "reading libraries")
|
|
libraries = self.media.libraries()
|
|
lib_ids = self._upsert_libraries(provider_id, libraries)
|
|
self._seed_keep_all_libraries()
|
|
|
|
# History first: item rollups need it in place.
|
|
self._progress(scan_id, "reading watch history")
|
|
coverage = self._ingest_history(provider_id, mode, result)
|
|
|
|
for lib in libraries:
|
|
self._progress(scan_id, "scanning %s" % lib.title)
|
|
self._ingest_library(provider_id, lib, lib_ids[lib.provider_key],
|
|
scan_id, result)
|
|
|
|
self._progress(scan_id, "rolling up")
|
|
self._apply_watch_rollups(provider_id)
|
|
self._correct_added_at(provider_id)
|
|
self._rollup_seasons(provider_id)
|
|
self._rollup_shows(provider_id)
|
|
self._apply_pre_history(provider_id, coverage)
|
|
|
|
if mode == "full":
|
|
self._mark_missing(provider_id, scan_id, result)
|
|
|
|
self._progress(scan_id, "resolving keeps")
|
|
keeps.resolve_all(self.db)
|
|
orphans = keeps.stamp_matches(self.db, scan_id)
|
|
if orphans:
|
|
self._warn("%d keep mark(s) matched nothing this scan" % orphans)
|
|
|
|
self.db.execute("UPDATE provider SET last_scan_id=? WHERE id=?", (scan_id, provider_id))
|
|
|
|
# ── provider / libraries ─────────────────────────────────────────────
|
|
|
|
def _upsert_provider(self, info) -> int:
|
|
now = int(time.time())
|
|
self.db.execute(
|
|
"INSERT INTO provider (kind, name, base_url, server_id, version, created_at) "
|
|
"VALUES (?,?,?,?,?,?) ON CONFLICT(kind, base_url) DO UPDATE SET "
|
|
"name=excluded.name, server_id=excluded.server_id, version=excluded.version",
|
|
(info.kind, info.name, info.base_url, info.server_id, info.version, now),
|
|
)
|
|
return self.db.scalar(
|
|
"SELECT id FROM provider WHERE kind=? AND base_url=?",
|
|
(info.kind, info.base_url),
|
|
)
|
|
|
|
def _check_history_pairing(self, provider_id: int, media_info) -> None:
|
|
"""Refuse to join history from a different Plex server (§4.11)."""
|
|
if self.history is None or self.history.name != "tautulli":
|
|
return
|
|
try:
|
|
hinfo = self.history.server_info()
|
|
except ProviderError as e:
|
|
self._warn("could not read Tautulli server info: %s" % e)
|
|
return
|
|
if hinfo.server_id and media_info.server_id and hinfo.server_id != media_info.server_id:
|
|
raise ProviderError(
|
|
"Tautulli is watching a different Plex server "
|
|
"(%s != %s) - refusing to join unrelated history data"
|
|
% (hinfo.server_id[:8], media_info.server_id[:8])
|
|
)
|
|
|
|
def _upsert_libraries(self, provider_id: int, libraries: list[Library]) -> dict[str, int]:
|
|
out = {}
|
|
now = int(time.time())
|
|
for lib in libraries:
|
|
self.db.execute(
|
|
"INSERT INTO library (provider_id, provider_key, title, kind, locations, scanned_at) "
|
|
"VALUES (?,?,?,?,?,?) ON CONFLICT(provider_id, provider_key) DO UPDATE SET "
|
|
"title=excluded.title, kind=excluded.kind, locations=excluded.locations, "
|
|
"scanned_at=excluded.scanned_at",
|
|
(provider_id, lib.provider_key, lib.title, lib.kind,
|
|
json.dumps(lib.locations), now),
|
|
)
|
|
out[lib.provider_key] = self.db.scalar(
|
|
"SELECT id FROM library WHERE provider_id=? AND provider_key=?",
|
|
(provider_id, lib.provider_key),
|
|
)
|
|
return out
|
|
|
|
def _seed_keep_all_libraries(self) -> None:
|
|
"""Apply KEEP_ALL_LIBRARIES once, on first run only.
|
|
|
|
Empty by default: nothing is ever kept unless a person says so (§6.6).
|
|
Re-applying on every start would silently re-enable a rule the user
|
|
turned off in the UI, so a marker setting guards it.
|
|
"""
|
|
if not self.cfg.keep_all_libraries:
|
|
return
|
|
if self.db.get_setting("keep_all_seeded"):
|
|
return
|
|
for title in self.cfg.keep_all_libraries:
|
|
row = self.db.one("SELECT id FROM library WHERE title = ?", (title,))
|
|
if row:
|
|
self.db.execute("UPDATE library SET keep_all = 1 WHERE id = ?", (row["id"],))
|
|
log.info("seeded keep_all for library %r", title)
|
|
else:
|
|
self._warn("KEEP_ALL_LIBRARIES names %r, which is not a library" % title)
|
|
self.db.set_setting("keep_all_seeded", "1")
|
|
|
|
# ── history ──────────────────────────────────────────────────────────
|
|
|
|
def _disposition(self, pc: int | None) -> str:
|
|
if pc is None:
|
|
return "completed" # Plex fallback: only a play is recorded (§4.11)
|
|
if pc >= self.cfg.completion_threshold:
|
|
return "completed"
|
|
if pc < self.cfg.abandon_ceiling:
|
|
return "abandoned"
|
|
return "partial"
|
|
|
|
def _ingest_history(self, provider_id: int, mode: str, result: ScanResult) -> Coverage | None:
|
|
if self.history is None:
|
|
return None
|
|
|
|
try:
|
|
for acct in self.history.accounts():
|
|
self.db.execute(
|
|
"INSERT INTO account (provider_id, account_id, name, friendly_name) "
|
|
"VALUES (?,?,?,?) ON CONFLICT(provider_id, account_id) DO UPDATE SET "
|
|
"name=excluded.name, friendly_name=excluded.friendly_name",
|
|
(provider_id, acct.account_id, acct.name, acct.friendly_name),
|
|
)
|
|
except ProviderError as e:
|
|
self._warn("could not read accounts: %s" % e)
|
|
|
|
since = None
|
|
if mode != "full":
|
|
since = self.db.scalar(
|
|
"SELECT MAX(viewed_at) FROM watch_event WHERE provider_id=? AND source=?",
|
|
(provider_id, self.history.name),
|
|
)
|
|
|
|
window = self.cfg.session_merge_window_h * 3600
|
|
batch: list[tuple] = []
|
|
added = 0
|
|
|
|
def flush():
|
|
nonlocal added, batch
|
|
if not batch:
|
|
return
|
|
cur = self.db.executemany(
|
|
"INSERT INTO watch_event (provider_id, source, source_row_id, reference_id, "
|
|
"provider_item_id, account_id, viewed_at, stopped_at, play_duration_s, "
|
|
"paused_counter_s, percent_complete, watched_status, disposition, session_id, "
|
|
"media_type, platform) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) "
|
|
"ON CONFLICT(provider_id, source, source_row_id) DO NOTHING",
|
|
batch,
|
|
)
|
|
added += cur.rowcount if cur.rowcount and cur.rowcount > 0 else 0
|
|
batch = []
|
|
|
|
for ev in self.history.watch_events(since):
|
|
# Session key: same item + same user within the merge window (§4.9).
|
|
bucket = ev.viewed_at // window if window > 0 else ev.viewed_at
|
|
session_id = "%s:%s:%s" % (ev.provider_item_id, ev.account_id or "-", bucket)
|
|
batch.append((
|
|
provider_id, ev.source, ev.source_row_id, ev.reference_id,
|
|
ev.provider_item_id, ev.account_id, ev.viewed_at, ev.stopped_at,
|
|
ev.play_duration_s, ev.paused_counter_s, ev.percent_complete,
|
|
ev.watched_status, self._disposition(ev.percent_complete), session_id,
|
|
ev.media_type, ev.platform,
|
|
))
|
|
if len(batch) >= 2000:
|
|
flush()
|
|
flush()
|
|
result.events_added = added
|
|
|
|
cov = self.db.one(
|
|
"SELECT MIN(viewed_at) AS lo, MAX(viewed_at) AS hi, COUNT(*) AS n "
|
|
"FROM watch_event WHERE provider_id=? AND source=?",
|
|
(provider_id, self.history.name),
|
|
)
|
|
coverage = Coverage(cov["lo"], cov["hi"], cov["n"] or 0)
|
|
self.db.execute(
|
|
"INSERT INTO history_coverage (provider_id, source, earliest_event_at, "
|
|
"latest_event_at, event_count, updated_at) VALUES (?,?,?,?,?,?) "
|
|
"ON CONFLICT(provider_id, source) DO UPDATE SET "
|
|
"earliest_event_at=excluded.earliest_event_at, "
|
|
"latest_event_at=excluded.latest_event_at, "
|
|
"event_count=excluded.event_count, updated_at=excluded.updated_at",
|
|
(provider_id, self.history.name, coverage.earliest_event_at,
|
|
coverage.latest_event_at, coverage.event_count, int(time.time())),
|
|
)
|
|
return coverage
|
|
|
|
# ── items ────────────────────────────────────────────────────────────
|
|
|
|
def _ingest_library(self, provider_id: int, lib: Library, library_id: int,
|
|
scan_id: int, result: ScanResult) -> None:
|
|
show_guids: dict[str, str] = {}
|
|
if lib.kind == "show":
|
|
try:
|
|
show_guids = self.media.show_guids(lib)
|
|
except Exception as e: # noqa: BLE001
|
|
self._warn("could not read show GUIDs for %s: %s" % (lib.title, e))
|
|
|
|
seasons: dict[str, dict] = {}
|
|
shows: dict[str, dict] = {}
|
|
n = 0
|
|
|
|
conn = self.db.conn
|
|
conn.execute("BEGIN IMMEDIATE")
|
|
try:
|
|
for item in self.media.items(lib):
|
|
n += 1
|
|
if item.kind == "movie":
|
|
self._upsert_movie(provider_id, library_id, item, scan_id, result)
|
|
else:
|
|
self._collect_episode(provider_id, library_id, item, show_guids,
|
|
seasons, shows, scan_id, result)
|
|
# season/show container rows
|
|
for key, s in seasons.items():
|
|
self._upsert_season(provider_id, library_id, s, scan_id, result)
|
|
for key, s in shows.items():
|
|
self._upsert_show(provider_id, library_id, s, scan_id, result)
|
|
conn.execute("COMMIT")
|
|
except Exception:
|
|
conn.execute("ROLLBACK")
|
|
raise
|
|
|
|
result.items_seen += n
|
|
|
|
def _upsert_movie(self, provider_id, library_id, item: Item, scan_id, result) -> None:
|
|
existing = self.db.one(
|
|
"SELECT id FROM media_item WHERE provider_id=? AND provider_item_id=?",
|
|
(provider_id, item.provider_item_id),
|
|
)
|
|
primary = item.parts[0].file_path if item.parts else None
|
|
now = int(time.time())
|
|
vals = (
|
|
provider_id, library_id, item.provider_item_id, "movie", item.guid, None,
|
|
item.title, item.sort_title, item.year, None, None,
|
|
item.added_at, item.added_at, item.updated_at, 0, item.size_bytes,
|
|
item.duration_ms, len(item.parts), primary, item.resolution,
|
|
item.video_codec, item.view_count, item.last_viewed_at,
|
|
"present", scan_id, scan_id, now,
|
|
)
|
|
if existing:
|
|
self.db.execute(
|
|
"UPDATE media_item SET library_id=?, guid=?, title=?, sort_title=?, year=?, "
|
|
"provider_added_at=?, updated_at=?, size_bytes=?, duration_ms=?, part_count=?, "
|
|
"primary_path=?, resolution=?, video_codec=?, provider_view_count=?, "
|
|
"provider_last_viewed_at=?, status='present', last_seen_scan_id=? WHERE id=?",
|
|
(library_id, item.guid, item.title, item.sort_title, item.year,
|
|
item.added_at, item.updated_at, item.size_bytes, item.duration_ms,
|
|
len(item.parts), primary, item.resolution, item.video_codec,
|
|
item.view_count, item.last_viewed_at, scan_id, existing["id"]),
|
|
)
|
|
item_id = existing["id"]
|
|
result.items_updated += 1
|
|
else:
|
|
cur = self.db.execute(
|
|
"INSERT INTO media_item (provider_id, library_id, provider_item_id, kind, "
|
|
"guid, show_guid, title, sort_title, year, parent_id, season_number, "
|
|
"added_at, provider_added_at, updated_at, episode_count, size_bytes, "
|
|
"duration_ms, part_count, primary_path, resolution, video_codec, "
|
|
"provider_view_count, provider_last_viewed_at, status, "
|
|
"first_seen_scan_id, last_seen_scan_id, first_seen_at) "
|
|
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", vals,
|
|
)
|
|
item_id = cur.lastrowid
|
|
result.items_added += 1
|
|
|
|
# Parts are replaced wholesale — cheap, and the only way to stay correct
|
|
# when a version is removed or a split file is re-encoded into one.
|
|
self.db.execute("DELETE FROM media_part WHERE media_item_id=?", (item_id,))
|
|
self._insert_parts(item, media_item_id=item_id)
|
|
|
|
def _insert_parts(self, item: Item, *, media_item_id=None, episode_id=None) -> None:
|
|
if not item.parts:
|
|
return
|
|
self.db.executemany(
|
|
"INSERT INTO media_part (media_item_id, episode_id, provider_part_id, file_path, "
|
|
"size_bytes, container, resolution, video_codec, audio_codec, bitrate) "
|
|
"VALUES (?,?,?,?,?,?,?,?,?,?)",
|
|
[(media_item_id, episode_id, p.provider_part_id, p.file_path, p.size_bytes,
|
|
p.container, p.resolution, p.video_codec, p.audio_codec, p.bitrate)
|
|
for p in item.parts],
|
|
)
|
|
|
|
def _collect_episode(self, provider_id, library_id, item: Item, show_guids,
|
|
seasons, shows, scan_id, result) -> None:
|
|
if not item.season_id:
|
|
self._warn("episode %s has no season; skipped" % item.provider_item_id)
|
|
return
|
|
show_guid = show_guids.get(item.show_id or "") or None
|
|
|
|
s = seasons.setdefault(item.season_id, {
|
|
"provider_item_id": item.season_id,
|
|
"show_id": item.show_id,
|
|
"show_guid": show_guid,
|
|
"show_title": item.show_title,
|
|
"season_number": item.season_number,
|
|
"episodes": [],
|
|
})
|
|
s["episodes"].append(item)
|
|
|
|
sh = shows.setdefault(item.show_id or "?", {
|
|
"provider_item_id": item.show_id,
|
|
"guid": show_guid,
|
|
"title": item.show_title or "(unknown show)",
|
|
"seasons": set(),
|
|
})
|
|
sh["seasons"].add(item.season_id)
|
|
|
|
def _upsert_season(self, provider_id, library_id, s: dict, scan_id, result) -> None:
|
|
eps: list[Item] = s["episodes"]
|
|
title = "Season %s" % (s["season_number"] if s["season_number"] is not None else "?")
|
|
existing = self.db.one(
|
|
"SELECT id FROM media_item WHERE provider_id=? AND provider_item_id=?",
|
|
(provider_id, s["provider_item_id"]),
|
|
)
|
|
if existing:
|
|
season_id = existing["id"]
|
|
self.db.execute(
|
|
"UPDATE media_item SET library_id=?, show_guid=?, title=?, season_number=?, "
|
|
"status='present', last_seen_scan_id=? WHERE id=?",
|
|
(library_id, s["show_guid"], title, s["season_number"], scan_id, season_id),
|
|
)
|
|
result.items_updated += 1
|
|
else:
|
|
cur = self.db.execute(
|
|
"INSERT INTO media_item (provider_id, library_id, provider_item_id, kind, "
|
|
"show_guid, title, season_number, status, first_seen_scan_id, last_seen_scan_id) "
|
|
"VALUES (?,?,?,'season',?,?,?,'present',?,?)",
|
|
(provider_id, library_id, s["provider_item_id"], s["show_guid"],
|
|
title, s["season_number"], scan_id, scan_id),
|
|
)
|
|
season_id = cur.lastrowid
|
|
result.items_added += 1
|
|
|
|
for ep in eps:
|
|
row = self.db.one("SELECT id FROM episode WHERE provider_item_id=?",
|
|
(ep.provider_item_id,))
|
|
primary_count = len(ep.parts)
|
|
if row:
|
|
ep_id = row["id"]
|
|
self.db.execute(
|
|
"UPDATE episode SET season_item_id=?, episode_number=?, title=?, "
|
|
"provider_added_at=?, provider_last_viewed_at=?, duration_ms=?, "
|
|
"size_bytes=?, part_count=?, "
|
|
"status='present', last_seen_scan_id=? WHERE id=?",
|
|
(season_id, ep.episode_number, ep.title, ep.added_at,
|
|
ep.last_viewed_at, ep.duration_ms,
|
|
ep.size_bytes, primary_count, scan_id, ep_id),
|
|
)
|
|
else:
|
|
cur = self.db.execute(
|
|
"INSERT INTO episode (season_item_id, provider_item_id, episode_number, "
|
|
"title, added_at, provider_added_at, provider_last_viewed_at, "
|
|
"duration_ms, size_bytes, part_count, "
|
|
"status, last_seen_scan_id, first_seen_at) "
|
|
"VALUES (?,?,?,?,?,?,?,?,?,?, 'present', ?, ?)",
|
|
(season_id, ep.provider_item_id, ep.episode_number, ep.title,
|
|
ep.added_at, ep.added_at, ep.last_viewed_at, ep.duration_ms,
|
|
ep.size_bytes, primary_count, scan_id, int(time.time())),
|
|
)
|
|
ep_id = cur.lastrowid
|
|
self.db.execute("DELETE FROM media_part WHERE episode_id=?", (ep_id,))
|
|
self._insert_parts(ep, episode_id=ep_id)
|
|
|
|
def _upsert_show(self, provider_id, library_id, sh: dict, scan_id, result) -> None:
|
|
if not sh["provider_item_id"]:
|
|
return
|
|
existing = self.db.one(
|
|
"SELECT id FROM media_item WHERE provider_id=? AND provider_item_id=?",
|
|
(provider_id, sh["provider_item_id"]),
|
|
)
|
|
if existing:
|
|
show_id = existing["id"]
|
|
self.db.execute(
|
|
"UPDATE media_item SET library_id=?, guid=?, title=?, status='present', "
|
|
"last_seen_scan_id=? WHERE id=?",
|
|
(library_id, sh["guid"], sh["title"], scan_id, show_id),
|
|
)
|
|
else:
|
|
cur = self.db.execute(
|
|
"INSERT INTO media_item (provider_id, library_id, provider_item_id, kind, "
|
|
"guid, title, status, first_seen_scan_id, last_seen_scan_id) "
|
|
"VALUES (?,?,?,'show',?,?,'present',?,?)",
|
|
(provider_id, library_id, sh["provider_item_id"], sh["guid"],
|
|
sh["title"], scan_id, scan_id),
|
|
)
|
|
show_id = cur.lastrowid
|
|
# link seasons to their show
|
|
self.db.execute(
|
|
"UPDATE media_item SET parent_id=? WHERE provider_id=? AND kind='season' "
|
|
"AND provider_item_id IN (%s)" % ",".join("?" * len(sh["seasons"])),
|
|
tuple([show_id, provider_id] + list(sh["seasons"])),
|
|
)
|
|
|
|
# ── rollups ──────────────────────────────────────────────────────────
|
|
|
|
def _apply_watch_rollups(self, provider_id: int) -> None:
|
|
"""Aggregate watch_event onto movies and episodes.
|
|
|
|
Distinct sessions, not raw events: a paused-and-resumed play is one
|
|
viewing (§4.9). Everything is recomputed from scratch each scan, which is
|
|
what makes re-running a scan idempotent.
|
|
"""
|
|
c = self.db.conn
|
|
c.execute("""
|
|
UPDATE media_item SET watch_count=0, partial_count=0, abandoned_count=0,
|
|
last_watched_at=NULL, last_touched_at=NULL, first_watched_at=NULL,
|
|
distinct_watcher_count=0, avg_percent_complete=NULL
|
|
WHERE kind='movie'
|
|
""")
|
|
c.execute("""
|
|
UPDATE episode SET watch_count=0, partial_count=0, abandoned_count=0,
|
|
last_watched_at=NULL, last_touched_at=NULL, first_watched_at=NULL
|
|
""")
|
|
|
|
agg = """
|
|
SELECT provider_item_id AS pid,
|
|
COUNT(DISTINCT CASE WHEN disposition='completed' THEN session_id END) AS completed,
|
|
COUNT(DISTINCT CASE WHEN disposition='partial' THEN session_id END) AS partial,
|
|
COUNT(DISTINCT CASE WHEN disposition='abandoned' THEN session_id END) AS abandoned,
|
|
MAX(CASE WHEN disposition='completed' THEN viewed_at END) AS last_watched,
|
|
MIN(CASE WHEN disposition='completed' THEN viewed_at END) AS first_watched,
|
|
MAX(viewed_at) AS last_touched,
|
|
COUNT(DISTINCT account_id) AS watchers,
|
|
AVG(percent_complete) AS avg_pc
|
|
FROM watch_event WHERE provider_id = ?
|
|
GROUP BY provider_item_id
|
|
"""
|
|
c.execute("DROP TABLE IF EXISTS _wagg")
|
|
c.execute("CREATE TEMP TABLE _wagg AS " + agg, (provider_id,))
|
|
c.execute("CREATE INDEX _wagg_pid ON _wagg(pid)")
|
|
|
|
c.execute("""
|
|
UPDATE media_item SET
|
|
watch_count = COALESCE((SELECT completed FROM _wagg WHERE pid = media_item.provider_item_id), 0),
|
|
partial_count = COALESCE((SELECT partial FROM _wagg WHERE pid = media_item.provider_item_id), 0),
|
|
abandoned_count = COALESCE((SELECT abandoned FROM _wagg WHERE pid = media_item.provider_item_id), 0),
|
|
last_watched_at = (SELECT last_watched FROM _wagg WHERE pid = media_item.provider_item_id),
|
|
first_watched_at = (SELECT first_watched FROM _wagg WHERE pid = media_item.provider_item_id),
|
|
last_touched_at = (SELECT last_touched FROM _wagg WHERE pid = media_item.provider_item_id),
|
|
distinct_watcher_count = COALESCE((SELECT watchers FROM _wagg WHERE pid = media_item.provider_item_id), 0),
|
|
avg_percent_complete = (SELECT avg_pc FROM _wagg WHERE pid = media_item.provider_item_id)
|
|
WHERE kind = 'movie'
|
|
""")
|
|
c.execute("""
|
|
UPDATE episode SET
|
|
watch_count = COALESCE((SELECT completed FROM _wagg WHERE pid = episode.provider_item_id), 0),
|
|
partial_count = COALESCE((SELECT partial FROM _wagg WHERE pid = episode.provider_item_id), 0),
|
|
abandoned_count = COALESCE((SELECT abandoned FROM _wagg WHERE pid = episode.provider_item_id), 0),
|
|
last_watched_at = (SELECT last_watched FROM _wagg WHERE pid = episode.provider_item_id),
|
|
first_watched_at = (SELECT first_watched FROM _wagg WHERE pid = episode.provider_item_id),
|
|
last_touched_at = (SELECT last_touched FROM _wagg WHERE pid = episode.provider_item_id)
|
|
""")
|
|
|
|
def _correct_added_at(self, provider_id: int) -> None:
|
|
"""Repair added_at where Plex's value is provably wrong.
|
|
|
|
Plex's addedAt tracks the FILE, not the library entry: replace or
|
|
re-encode a file and Date Added resets while the item, its ratingKey and
|
|
its watch history all survive. Measured on the live server, 19% of items
|
|
report a view EARLIER than their added date.
|
|
|
|
A view is proof the item already existed, so the earliest view is a
|
|
lower bound on the true add date. Two independent witnesses:
|
|
|
|
* Plex's own lastViewedAt, which reaches back as far as the server does
|
|
* MediaShelf's first completed play from Tautulli, which is more
|
|
precise but only covers the history window (here, 2025-03 onward)
|
|
|
|
The first version of this used only Tautulli and fired zero times,
|
|
because the wrong dates are mostly older than the history window. Plex's
|
|
lastViewedAt is the field that actually carries the evidence.
|
|
|
|
This is a lower bound, not the date Plex never kept. But it beats a value
|
|
we can prove impossible, and it matters: pre_history is derived from
|
|
added_at, so a wrongly-recent date promotes an item into the CONFIDENT
|
|
reclaim pool when it belongs in the uncertain one.
|
|
|
|
Items nobody has ever watched keep Plex's value; nothing contradicts it.
|
|
"""
|
|
c = self.db.conn
|
|
|
|
# episodes first, so season rollups inherit corrected dates
|
|
c.execute("UPDATE episode SET added_at = provider_added_at "
|
|
"WHERE provider_added_at IS NOT NULL")
|
|
c.execute("""
|
|
UPDATE episode SET added_at = MIN(
|
|
COALESCE(added_at, 253402300799),
|
|
COALESCE(NULLIF(first_watched_at, 0), 253402300799),
|
|
COALESCE(NULLIF(provider_last_viewed_at, 0), 253402300799))
|
|
WHERE (first_watched_at > 0 OR provider_last_viewed_at > 0)
|
|
""")
|
|
|
|
c.execute("UPDATE media_item SET added_at = provider_added_at, "
|
|
"added_at_source = 'provider' "
|
|
"WHERE kind = 'movie' AND provider_added_at IS NOT NULL "
|
|
"AND provider_id = ?", (provider_id,))
|
|
cur = c.execute("""
|
|
UPDATE media_item SET added_at = MIN(
|
|
COALESCE(added_at, 253402300799),
|
|
COALESCE(NULLIF(first_watched_at, 0), 253402300799),
|
|
COALESCE(NULLIF(provider_last_viewed_at, 0), 253402300799)),
|
|
added_at_source = 'first_watch'
|
|
WHERE kind = 'movie' AND provider_id = ?
|
|
AND (first_watched_at > 0 OR provider_last_viewed_at > 0)
|
|
AND MIN(COALESCE(NULLIF(first_watched_at, 0), 253402300799),
|
|
COALESCE(NULLIF(provider_last_viewed_at, 0), 253402300799))
|
|
< COALESCE(added_at, 253402300799)
|
|
""", (provider_id,))
|
|
corrected = cur.rowcount if cur.rowcount and cur.rowcount > 0 else 0
|
|
if corrected:
|
|
self._warn(
|
|
"%d movie(s) had a Plex addedAt later than a recorded view; "
|
|
"corrected to the earliest view (Plex's Date Added follows the "
|
|
"file, so replacing one resets it)" % corrected)
|
|
|
|
def _rollup_seasons(self, provider_id: int) -> None:
|
|
c = self.db.conn
|
|
c.execute("""
|
|
UPDATE media_item SET
|
|
episode_count = COALESCE((SELECT COUNT(*) FROM episode e
|
|
WHERE e.season_item_id = media_item.id AND e.status='present'), 0),
|
|
size_bytes = COALESCE((SELECT SUM(e.size_bytes) FROM episode e
|
|
WHERE e.season_item_id = media_item.id AND e.status='present'), 0),
|
|
duration_ms = COALESCE((SELECT SUM(e.duration_ms) FROM episode e
|
|
WHERE e.season_item_id = media_item.id AND e.status='present'), 0),
|
|
part_count = COALESCE((SELECT SUM(e.part_count) FROM episode e
|
|
WHERE e.season_item_id = media_item.id AND e.status='present'), 0),
|
|
added_at = (SELECT MIN(e.added_at) FROM episode e
|
|
WHERE e.season_item_id = media_item.id AND e.status='present'),
|
|
watch_count = COALESCE((SELECT SUM(e.watch_count) FROM episode e
|
|
WHERE e.season_item_id = media_item.id), 0),
|
|
partial_count = COALESCE((SELECT SUM(e.partial_count) FROM episode e
|
|
WHERE e.season_item_id = media_item.id), 0),
|
|
abandoned_count = COALESCE((SELECT SUM(e.abandoned_count) FROM episode e
|
|
WHERE e.season_item_id = media_item.id), 0),
|
|
last_watched_at = (SELECT MAX(e.last_watched_at) FROM episode e
|
|
WHERE e.season_item_id = media_item.id),
|
|
last_touched_at = (SELECT MAX(e.last_touched_at) FROM episode e
|
|
WHERE e.season_item_id = media_item.id)
|
|
WHERE kind = 'season' AND provider_id = ?
|
|
""", (provider_id,))
|
|
|
|
c.execute("""
|
|
UPDATE media_item SET provider_added_at = (
|
|
SELECT MIN(e.provider_added_at) FROM episode e
|
|
WHERE e.season_item_id = media_item.id AND e.status='present')
|
|
WHERE kind = 'season' AND provider_id = ?
|
|
""", (provider_id,))
|
|
|
|
c.execute("""
|
|
UPDATE media_item SET added_at_source = CASE
|
|
WHEN provider_added_at IS NOT NULL AND added_at < provider_added_at
|
|
THEN 'first_watch' ELSE 'provider' END
|
|
WHERE kind = 'season' AND provider_id = ?
|
|
""", (provider_id,))
|
|
|
|
# distinct watchers across the season's episodes
|
|
c.execute("""
|
|
UPDATE media_item SET distinct_watcher_count = COALESCE((
|
|
SELECT COUNT(DISTINCT w.account_id) FROM watch_event w
|
|
JOIN episode e ON e.provider_item_id = w.provider_item_id
|
|
WHERE e.season_item_id = media_item.id), 0)
|
|
WHERE kind = 'season' AND provider_id = ?
|
|
""", (provider_id,))
|
|
|
|
# representative path: the common directory of its episodes
|
|
c.execute("""
|
|
UPDATE media_item SET primary_path = (
|
|
SELECT p.file_path FROM media_part p
|
|
JOIN episode e ON e.id = p.episode_id
|
|
WHERE e.season_item_id = media_item.id
|
|
ORDER BY e.episode_number LIMIT 1)
|
|
WHERE kind = 'season' AND provider_id = ?
|
|
""", (provider_id,))
|
|
c.execute("""
|
|
UPDATE media_item SET resolution = (
|
|
SELECT p.resolution FROM media_part p
|
|
JOIN episode e ON e.id = p.episode_id
|
|
WHERE e.season_item_id = media_item.id AND p.resolution IS NOT NULL
|
|
LIMIT 1)
|
|
WHERE kind = 'season' AND provider_id = ?
|
|
""", (provider_id,))
|
|
|
|
def _rollup_shows(self, provider_id: int) -> None:
|
|
self.db.execute("""
|
|
UPDATE media_item SET
|
|
episode_count = COALESCE((SELECT SUM(s.episode_count) FROM media_item s
|
|
WHERE s.parent_id = media_item.id), 0),
|
|
size_bytes = COALESCE((SELECT SUM(s.size_bytes) FROM media_item s
|
|
WHERE s.parent_id = media_item.id), 0),
|
|
part_count = COALESCE((SELECT SUM(s.part_count) FROM media_item s
|
|
WHERE s.parent_id = media_item.id), 0),
|
|
duration_ms = COALESCE((SELECT SUM(s.duration_ms) FROM media_item s
|
|
WHERE s.parent_id = media_item.id), 0),
|
|
added_at = (SELECT MIN(s.added_at) FROM media_item s
|
|
WHERE s.parent_id = media_item.id),
|
|
watch_count = COALESCE((SELECT SUM(s.watch_count) FROM media_item s
|
|
WHERE s.parent_id = media_item.id), 0),
|
|
abandoned_count = COALESCE((SELECT SUM(s.abandoned_count) FROM media_item s
|
|
WHERE s.parent_id = media_item.id), 0),
|
|
last_watched_at = (SELECT MAX(s.last_watched_at) FROM media_item s
|
|
WHERE s.parent_id = media_item.id),
|
|
last_touched_at = (SELECT MAX(s.last_touched_at) FROM media_item s
|
|
WHERE s.parent_id = media_item.id)
|
|
WHERE kind = 'show' AND provider_id = ?
|
|
""", (provider_id,))
|
|
|
|
def _apply_pre_history(self, provider_id: int, coverage: Coverage | None) -> None:
|
|
"""Flag items added before watch history began (§4.11).
|
|
|
|
On the measured library this is the majority state, not an edge case.
|
|
"""
|
|
self.db.execute("UPDATE media_item SET pre_history = 0 WHERE provider_id = ?",
|
|
(provider_id,))
|
|
if not coverage or not coverage.earliest_event_at:
|
|
return
|
|
self.db.execute(
|
|
"UPDATE media_item SET pre_history = 1 "
|
|
"WHERE provider_id = ? AND added_at IS NOT NULL AND added_at > 0 AND added_at < ?",
|
|
(provider_id, coverage.earliest_event_at),
|
|
)
|
|
|
|
def _mark_missing(self, provider_id: int, scan_id: int, result: ScanResult) -> None:
|
|
"""Items not seen this full sweep become 'missing', never deleted (§5.4)."""
|
|
cur = self.db.execute(
|
|
"UPDATE media_item SET status='missing' "
|
|
"WHERE provider_id=? AND status='present' "
|
|
"AND (last_seen_scan_id IS NULL OR last_seen_scan_id != ?)",
|
|
(provider_id, scan_id),
|
|
)
|
|
result.items_missing = cur.rowcount if cur.rowcount and cur.rowcount > 0 else 0
|
|
self.db.execute(
|
|
"UPDATE episode SET status='missing' "
|
|
"WHERE status='present' AND (last_seen_scan_id IS NULL OR last_seen_scan_id != ?)",
|
|
(scan_id,),
|
|
)
|