The OpenSubtitles backfill worker already collects which subtitle languages exist for each title (movies.subtitle_langs / shows.subtitle_langs), but nothing showed it. Now the detail payload returns it (parsed to a list) and the hero renders a 'Subtitles: English · Spanish · …' CC-tinted chip row under the genres — so you can tell subs exist before grabbing the file. Hidden entirely when there's no data. (The clearlogo hero was already implemented, so this targets the one genuinely invisible enrichment field.) Also bound the per-iteration loop var in the ratings-breakdown counter (pre-existing ruff B023). 154 video tests green, ruff clean.
2713 lines
135 KiB
Python
2713 lines
135 KiB
Python
"""SoulSync — VIDEO side database (database/video_library.db).
|
||
|
||
ISOLATION CONTRACT: this module owns a SEPARATE SQLite file from the music
|
||
library and imports NOTHING from the music database layer. Music code never
|
||
imports this; this never imports music. A migration bug, corruption, or reset
|
||
here cannot touch music data, and the two never contend for the same write lock.
|
||
|
||
Conventions mirror database/music_database.py on purpose (so the two feel the
|
||
same operationally) — WAL journal, foreign keys ON, a 30s busy timeout, Row
|
||
factory, a once-per-process init guard, and PRAGMA user_version as a schema
|
||
backstop — but the implementations are independent.
|
||
|
||
The schema itself lives alongside this file in video_schema.sql and is executed
|
||
verbatim on first init.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import re
|
||
import sqlite3
|
||
import threading
|
||
from datetime import datetime, timedelta, timezone
|
||
from pathlib import Path
|
||
|
||
from utils.logging_config import get_logger
|
||
|
||
logger = get_logger("video_database")
|
||
|
||
# Bump when video_schema.sql changes in a way worth recording. Stored in
|
||
# PRAGMA user_version as a backstop indicator (nothing gates on it yet).
|
||
SCHEMA_VERSION = 13
|
||
|
||
_DEFAULT_DB_PATH = "database/video_library.db"
|
||
_SCHEMA_FILE = Path(__file__).resolve().parent / "video_schema.sql"
|
||
|
||
# Init runs once per database path per process (same guard style as music).
|
||
_init_lock = threading.Lock()
|
||
_initialized_paths: set[str] = set()
|
||
|
||
# Sort/letter key: title without a leading article, lowercased (so "The Matrix"
|
||
# files under M, like music's library).
|
||
_ARTICLE_RE = re.compile(r"^(the|a|an)\s+", re.IGNORECASE)
|
||
|
||
|
||
def _sort_title(title) -> str:
|
||
return _ARTICLE_RE.sub("", (title or "").strip()).lower()
|
||
|
||
|
||
# Enrichment plumbing (parallels music's per-source columns). Maps a service +
|
||
# content kind to (table, id_col, match_status_col, last_attempted_col).
|
||
_ENRICH = {
|
||
"tmdb": {
|
||
"movie": ("movies", "tmdb_id", "tmdb_match_status", "tmdb_last_attempted"),
|
||
"show": ("shows", "tmdb_id", "tmdb_match_status", "tmdb_last_attempted"),
|
||
},
|
||
"tvdb": {
|
||
"show": ("shows", "tvdb_id", "tvdb_match_status", "tvdb_last_attempted"),
|
||
},
|
||
}
|
||
|
||
# Whitelist of metadata columns enrichment may write per table (guards against
|
||
# arbitrary keys; backfill semantics applied by the caller).
|
||
_ENRICH_META_COLS = {
|
||
"movies": {"overview", "backdrop_url", "logo_url", "release_date", "status", "content_rating",
|
||
"runtime_minutes", "studio", "tagline", "rating", "rating_critic",
|
||
"imdb_id", "tmdb_id"},
|
||
"shows": {"overview", "backdrop_url", "logo_url", "status", "network", "content_rating",
|
||
"tagline", "rating", "first_air_date", "last_air_date", "airs_time",
|
||
"imdb_id", "tmdb_id", "tvdb_id"},
|
||
}
|
||
|
||
# Backfill-worker plumbing (parallels _ENRICH, but for services that enrich an
|
||
# already-identified library item BY id rather than matching a title). Maps a
|
||
# service + kind to (table, status_col, attempted_col, where_has_required_id).
|
||
_BACKFILL = {
|
||
"fanart": {
|
||
"movie": ("movies", "fanart_status", "fanart_attempted",
|
||
"(tmdb_id IS NOT NULL OR imdb_id IS NOT NULL)"),
|
||
"show": ("shows", "fanart_status", "fanart_attempted", "tvdb_id IS NOT NULL"),
|
||
},
|
||
"opensubtitles": {
|
||
"movie": ("movies", "subs_status", "subs_attempted",
|
||
"(imdb_id IS NOT NULL OR tmdb_id IS NOT NULL)"),
|
||
"show": ("shows", "subs_status", "subs_attempted",
|
||
"(imdb_id IS NOT NULL OR tmdb_id IS NOT NULL)"),
|
||
},
|
||
}
|
||
# Columns each backfill service may gap-fill (whitelist; never clobbers server data).
|
||
_BACKFILL_COLS = {
|
||
"fanart": {"logo_url", "backdrop_url", "poster_url", "clearart_url", "banner_url"},
|
||
"opensubtitles": {"subtitle_langs"},
|
||
}
|
||
|
||
# Columns ensured on existing DBs (ALTER TABLE ADD COLUMN; idempotent).
|
||
_COLUMN_MIGRATIONS = [
|
||
("movies", "tmdb_match_status", "TEXT"),
|
||
("movies", "tmdb_last_attempted", "TEXT"),
|
||
("shows", "tmdb_match_status", "TEXT"),
|
||
("shows", "tmdb_last_attempted", "TEXT"),
|
||
("shows", "tvdb_match_status", "TEXT"),
|
||
("shows", "tvdb_last_attempted", "TEXT"),
|
||
# "capture everything" — richer metadata from the server.
|
||
("movies", "tagline", "TEXT"),
|
||
("movies", "rating", "REAL"),
|
||
("movies", "rating_critic", "REAL"),
|
||
("shows", "tagline", "TEXT"),
|
||
("shows", "rating", "REAL"),
|
||
("shows", "first_air_date", "TEXT"),
|
||
("shows", "last_air_date", "TEXT"),
|
||
("episodes", "still_url", "TEXT"),
|
||
("episodes", "rating", "REAL"),
|
||
("movies", "logo_url", "TEXT"),
|
||
("shows", "logo_url", "TEXT"),
|
||
("shows", "episodes_synced", "INTEGER NOT NULL DEFAULT 0"),
|
||
("movies", "imdb_rating", "REAL"), ("movies", "rt_rating", "INTEGER"),
|
||
("movies", "metacritic", "INTEGER"),
|
||
("shows", "imdb_rating", "REAL"), ("shows", "rt_rating", "INTEGER"),
|
||
("shows", "metacritic", "INTEGER"),
|
||
("movies", "ratings_synced", "INTEGER NOT NULL DEFAULT 0"),
|
||
("shows", "ratings_synced", "INTEGER NOT NULL DEFAULT 0"),
|
||
("shows", "airs_time", "TEXT"), # TVDB show air time, e.g. "21:00" (network local)
|
||
("video_watchlist", "state", "TEXT NOT NULL DEFAULT 'follow'"), # follow | mute (tombstone)
|
||
("video_wishlist", "still_url", "TEXT"), # episode still thumbnail (captured at add time)
|
||
("video_wishlist", "season_poster_url", "TEXT"), # the episode's season poster
|
||
("video_wishlist", "episode_overview", "TEXT"), # episode synopsis
|
||
# generic source bridge (YouTube channels/videos ride the existing tables)
|
||
("video_watchlist", "source", "TEXT NOT NULL DEFAULT 'tmdb'"),
|
||
("video_watchlist", "source_id", "TEXT"),
|
||
("video_wishlist", "source", "TEXT NOT NULL DEFAULT 'tmdb'"),
|
||
("video_wishlist", "source_id", "TEXT"),
|
||
("video_wishlist", "parent_source_id", "TEXT"), # owning channel youtube id (video rows)
|
||
# which source produced a channel's dates — NULL on legacy (pre-InnerTube) rows
|
||
# so they re-enrich once and upgrade to the full InnerTube catalog.
|
||
("youtube_channel_enrichment", "method", "TEXT"),
|
||
# per-video duration + approximate view count on the remembered catalog
|
||
("youtube_channel_videos", "duration", "TEXT"),
|
||
("youtube_channel_videos", "view_count", "INTEGER"),
|
||
# fanart.tv artwork backfill (gap-fill only; logo/backdrop/poster live already)
|
||
("movies", "clearart_url", "TEXT"), ("movies", "banner_url", "TEXT"),
|
||
("movies", "fanart_status", "TEXT"), ("movies", "fanart_attempted", "TEXT"),
|
||
("shows", "clearart_url", "TEXT"), ("shows", "banner_url", "TEXT"),
|
||
("shows", "fanart_status", "TEXT"), ("shows", "fanart_attempted", "TEXT"),
|
||
# OpenSubtitles availability backfill (which languages exist for a title)
|
||
("movies", "subtitle_langs", "TEXT"), # JSON array of language codes
|
||
("movies", "subs_status", "TEXT"), ("movies", "subs_attempted", "TEXT"),
|
||
("shows", "subtitle_langs", "TEXT"),
|
||
("shows", "subs_status", "TEXT"), ("shows", "subs_attempted", "TEXT"),
|
||
]
|
||
|
||
|
||
def _subtitle_langs_list(raw) -> list:
|
||
"""Parse the stored OpenSubtitles ``subtitle_langs`` JSON array into a list of
|
||
language codes for the detail payload. Returns [] for null/garbage so the UI
|
||
can simply hide the row when empty."""
|
||
if not raw:
|
||
return []
|
||
try:
|
||
v = json.loads(raw)
|
||
return [str(x) for x in v if x] if isinstance(v, list) else []
|
||
except (ValueError, TypeError):
|
||
return []
|
||
|
||
|
||
def youtube_surrogate_id(source_id: str) -> int:
|
||
"""A stable positive 60-bit int derived from a YouTube id, used as the
|
||
NOT NULL ``tmdb_id`` surrogate for non-tmdb rows so the existing
|
||
UNIQUE(kind, tmdb_id) dedup + group-by machinery keeps working unchanged.
|
||
Collision probability across realistic channel counts is negligible."""
|
||
h = hashlib.sha1((source_id or "").encode("utf-8")).hexdigest()
|
||
return int(h[:15], 16) # 60 bits — comfortably inside SQLite's signed 64-bit INTEGER
|
||
|
||
|
||
class VideoDatabase:
|
||
"""Connection + schema manager for the isolated video library DB."""
|
||
|
||
def __init__(self, database_path: str | None = None):
|
||
# Honour the env override (Docker mounts) the same way music does, but
|
||
# under a DISTINCT variable so the two databases never collide.
|
||
if database_path is None or database_path == _DEFAULT_DB_PATH:
|
||
database_path = os.environ.get("VIDEO_DATABASE_PATH", _DEFAULT_DB_PATH)
|
||
self.database_path = Path(database_path)
|
||
self.database_path.parent.mkdir(parents=True, exist_ok=True)
|
||
self._initialize_once()
|
||
|
||
# ── connection ──────────────────────────────────────────────────────────
|
||
def _get_connection(self) -> sqlite3.Connection:
|
||
"""A fresh connection with the standard pragmas applied."""
|
||
conn = sqlite3.connect(str(self.database_path), timeout=30.0)
|
||
conn.row_factory = sqlite3.Row
|
||
conn.execute("PRAGMA foreign_keys = ON")
|
||
conn.execute("PRAGMA journal_mode = WAL")
|
||
conn.execute("PRAGMA busy_timeout = 30000") # 30s
|
||
return conn
|
||
|
||
def connect(self) -> sqlite3.Connection:
|
||
"""Public connection factory — caller owns closing it.
|
||
|
||
Prefer using ``with db.connect() as conn:`` so commits/rollbacks and
|
||
close happen automatically.
|
||
"""
|
||
return self._get_connection()
|
||
|
||
# ── init ────────────────────────────────────────────────────────────────
|
||
def _initialize_once(self) -> None:
|
||
key = str(self.database_path.resolve())
|
||
with _init_lock:
|
||
if key in _initialized_paths:
|
||
return
|
||
self._initialize_database()
|
||
_initialized_paths.add(key)
|
||
|
||
def _initialize_database(self) -> None:
|
||
schema = _SCHEMA_FILE.read_text(encoding="utf-8")
|
||
conn = self._get_connection()
|
||
try:
|
||
conn.executescript(schema)
|
||
self._ensure_columns(conn)
|
||
self._ensure_indexes(conn)
|
||
conn.execute(f"PRAGMA user_version = {int(SCHEMA_VERSION)}")
|
||
conn.commit()
|
||
logger.info(
|
||
"Video database ready at %s (schema v%d)",
|
||
self.database_path, SCHEMA_VERSION,
|
||
)
|
||
except Exception:
|
||
conn.rollback()
|
||
logger.exception("Failed to initialize video database at %s", self.database_path)
|
||
raise
|
||
finally:
|
||
conn.close()
|
||
|
||
# Partial indexes that reference migration-added columns. They MUST run after
|
||
# _ensure_columns (the schema executescript runs first, before the ALTERs, so
|
||
# these would fail with "no such column" on an upgraded DB if placed there).
|
||
_POST_INDEXES = (
|
||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_video_wishlist_video "
|
||
"ON video_wishlist(source_id) WHERE kind = 'video'",
|
||
"CREATE INDEX IF NOT EXISTS idx_video_wishlist_channel "
|
||
"ON video_wishlist(parent_source_id) WHERE kind = 'video'",
|
||
)
|
||
|
||
@classmethod
|
||
def _ensure_indexes(cls, conn) -> None:
|
||
"""Create indexes that depend on migration-added columns (after columns exist)."""
|
||
for stmt in cls._POST_INDEXES:
|
||
conn.execute(stmt)
|
||
|
||
@staticmethod
|
||
def _ensure_columns(conn) -> None:
|
||
"""Add any new columns to an existing DB (idempotent ALTER TABLE)."""
|
||
for table, col, coltype in _COLUMN_MIGRATIONS:
|
||
cols = {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
||
if col not in cols:
|
||
conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} {coltype}")
|
||
|
||
# ── enrichment plumbing (per-source match status, like music) ─────────────
|
||
def enrichment_next(self, service: str, retry_days: int = 30, priority=None) -> dict | None:
|
||
"""Next item that needs enrichment for a service: pending (never tried)
|
||
first, then a not_found item older than retry_days. Returns
|
||
{kind, id, title, year, known_id} or None. ``known_id`` is the provider
|
||
id the media server already supplied (e.g. tmdb_id/tvdb_id) so the worker
|
||
can enrich BY ID instead of re-searching by title.
|
||
|
||
``priority`` ('movie'/'show') pins a kind to be processed first across the
|
||
queue — drives the modal's 'Process first everywhere' control."""
|
||
kinds = _ENRICH.get(service)
|
||
if not kinds:
|
||
return None
|
||
items = list(kinds.items())
|
||
if priority in kinds:
|
||
items.sort(key=lambda kv: 0 if kv[0] == priority else 1)
|
||
cutoff = (datetime.now(timezone.utc) - timedelta(days=retry_days)).strftime("%Y-%m-%d %H:%M:%S")
|
||
conn = self._get_connection()
|
||
|
||
def _row(row, kind, idc):
|
||
return {"kind": kind, "id": row["id"], "title": row["title"],
|
||
"year": row["year"], "known_id": row[idc]}
|
||
|
||
try:
|
||
for kind, (tbl, idc, sc, _ac) in items:
|
||
row = conn.execute(
|
||
f"SELECT id, title, year, {idc} FROM {tbl} WHERE {sc} IS NULL ORDER BY id LIMIT 1").fetchone()
|
||
if row:
|
||
return _row(row, kind, idc)
|
||
for kind, (tbl, idc, sc, ac) in items:
|
||
row = conn.execute(
|
||
f"SELECT id, title, year, {idc} FROM {tbl} "
|
||
f"WHERE {sc} IN ('not_found','error') "
|
||
f"AND ({ac} IS NULL OR {ac} < ?) ORDER BY {ac} LIMIT 1", (cutoff,)).fetchone()
|
||
if row:
|
||
return _row(row, kind, idc)
|
||
return None
|
||
finally:
|
||
conn.close()
|
||
|
||
def enrichment_apply(self, service: str, kind: str, item_id: int, matched: bool,
|
||
external_id=None, metadata: dict | None = None,
|
||
error: bool = False) -> None:
|
||
"""Record a match result: set match_status + last_attempted, the external
|
||
id (when matched), and any whitelisted metadata columns.
|
||
|
||
Status is one of 'matched' / 'not_found' / 'error'. 'error' means the
|
||
lookup CALL failed (network/rate-limit/timeout) — distinct from a genuine
|
||
'not_found' so a transient blip isn't permanently recorded as "no match"
|
||
(mirrors the music workers). Both 'not_found' and 'error' are retried by
|
||
enrichment_next after retry_days."""
|
||
spec = _ENRICH.get(service, {}).get(kind)
|
||
if not spec:
|
||
return
|
||
tbl, idc, sc, ac = spec
|
||
allowed = _ENRICH_META_COLS.get(tbl, set())
|
||
status = "matched" if matched else "error" if error else "not_found"
|
||
# On legacy DBs tmdb_id/tvdb_id may still carry a UNIQUE index; if a match
|
||
# would collide with another row's id we drop the id columns and keep the
|
||
# existing (authoritative) id, still recording status + metadata.
|
||
id_cols = {"tmdb_id", "tvdb_id", "imdb_id"}
|
||
|
||
def build(include_ids):
|
||
sets = [f"{sc}=?", f"{ac}=CURRENT_TIMESTAMP"]
|
||
params = [status]
|
||
if matched and external_id is not None and include_ids:
|
||
sets.append(f"{idc}=?")
|
||
params.append(external_id)
|
||
for col, val in (metadata or {}).items():
|
||
if val is None or col not in allowed:
|
||
continue
|
||
if not include_ids and col in id_cols:
|
||
continue
|
||
# BACKFILL: only fill a column the server left empty — enrichment
|
||
# fills gaps, it never clobbers data the media server provided.
|
||
sets.append(f"{col}=COALESCE(NULLIF({col}, ''), ?)")
|
||
params.append(val)
|
||
params.append(item_id)
|
||
return f"UPDATE {tbl} SET {', '.join(sets)} WHERE id=?", params
|
||
|
||
conn = self._get_connection()
|
||
try:
|
||
sql, params = build(True)
|
||
try:
|
||
conn.execute(sql, params)
|
||
except sqlite3.IntegrityError:
|
||
conn.rollback()
|
||
sql, params = build(False) # keep existing id, just record status/metadata
|
||
conn.execute(sql, params)
|
||
# Genres backfill — only when the item has none yet (enrichment fills
|
||
# the gap the server didn't). Written to the normalised link tables.
|
||
genres = (metadata or {}).get("genres")
|
||
link = {"movies": ("movie_genres", "movie_id"),
|
||
"shows": ("show_genres", "show_id")}.get(tbl)
|
||
if matched and genres and link:
|
||
lt, oc = link
|
||
has = conn.execute(f"SELECT 1 FROM {lt} WHERE {oc}=? LIMIT 1", (item_id,)).fetchone()
|
||
if not has:
|
||
self._set_genres(conn, lt, oc, item_id, genres)
|
||
# Cast/crew backfill — only when the item has none yet (gap-fill).
|
||
cast = (metadata or {}).get("cast")
|
||
crew = (metadata or {}).get("crew")
|
||
if matched and (cast or crew) and tbl in ("movies", "shows"):
|
||
oc = "movie_id" if tbl == "movies" else "show_id"
|
||
has = conn.execute(f"SELECT 1 FROM credits WHERE {oc}=? LIMIT 1", (item_id,)).fetchone()
|
||
if not has:
|
||
self._set_credits(conn, oc, item_id, cast or [], crew or [])
|
||
# Per-season poster backfill (TMDB) — fills only seasons the server
|
||
# left without art.
|
||
seasons_meta = (metadata or {}).get("seasons")
|
||
if matched and seasons_meta and tbl == "shows":
|
||
for s in seasons_meta:
|
||
sn, purl = s.get("season_number"), s.get("poster_url")
|
||
if sn is None or not purl:
|
||
continue
|
||
conn.execute(
|
||
"UPDATE seasons SET poster_url=COALESCE(NULLIF(poster_url, ''), ?) "
|
||
"WHERE show_id=? AND season_number=?", (purl, item_id, sn))
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
|
||
def enrichment_breakdown(self, service: str) -> dict:
|
||
if service == "omdb":
|
||
return self._ratings_breakdown()
|
||
if service == "ryd":
|
||
return self.youtube_enrich_breakdown("ryd_status")
|
||
if service == "sponsorblock":
|
||
return self.youtube_enrich_breakdown("sb_status")
|
||
if service in _BACKFILL:
|
||
return self.backfill_breakdown(service)
|
||
kinds = _ENRICH.get(service, {})
|
||
out = {}
|
||
conn = self._get_connection()
|
||
try:
|
||
for kind, (tbl, _idc, sc, _ac) in kinds.items():
|
||
out[kind] = {
|
||
"matched": conn.execute(f"SELECT COUNT(*) FROM {tbl} WHERE {sc}='matched'").fetchone()[0],
|
||
"not_found": conn.execute(f"SELECT COUNT(*) FROM {tbl} WHERE {sc}='not_found'").fetchone()[0],
|
||
"errors": conn.execute(f"SELECT COUNT(*) FROM {tbl} WHERE {sc}='error'").fetchone()[0],
|
||
"pending": conn.execute(f"SELECT COUNT(*) FROM {tbl} WHERE {sc} IS NULL").fetchone()[0],
|
||
}
|
||
# TMDB also cascades episode art (still) backfill from the show worker,
|
||
# so the manager sees episode coverage. Not a queue (matched = has a
|
||
# still; the rest are "pending" art) — kept out of the idle/pending
|
||
# calc by the worker so it never blocks "Complete".
|
||
if service == "tmdb":
|
||
total = conn.execute("SELECT COUNT(*) FROM episodes").fetchone()[0]
|
||
with_still = conn.execute(
|
||
"SELECT COUNT(*) FROM episodes WHERE still_url IS NOT NULL AND still_url<>''").fetchone()[0]
|
||
out["episode"] = {"matched": with_still, "not_found": 0, "errors": 0,
|
||
"pending": total - with_still, "coverage_only": True}
|
||
return out
|
||
finally:
|
||
conn.close()
|
||
|
||
# ── backfill-worker plumbing (artwork / subtitles, by id) ─────────────────
|
||
def backfill_next(self, service: str) -> dict | None:
|
||
"""Next library item needing a backfill service: a row that already has the
|
||
id the service needs and no status yet. Returns
|
||
{kind, id, title, tmdb_id, imdb_id[, tvdb_id]} or None."""
|
||
kinds = _BACKFILL.get(service)
|
||
if not kinds:
|
||
return None
|
||
conn = self._get_connection()
|
||
try:
|
||
for kind, (tbl, sc, _ac, has_id) in kinds.items():
|
||
cols = "id, title, tmdb_id, imdb_id" + (", tvdb_id" if tbl == "shows" else "")
|
||
row = conn.execute(
|
||
f"SELECT {cols} FROM {tbl} WHERE {sc} IS NULL AND {has_id} "
|
||
f"ORDER BY id LIMIT 1").fetchone()
|
||
if row:
|
||
d = dict(row)
|
||
d["kind"] = kind
|
||
return d
|
||
return None
|
||
finally:
|
||
conn.close()
|
||
|
||
def backfill_mark(self, service: str, kind: str, item_id: int, status: str,
|
||
columns: dict | None = None) -> None:
|
||
"""Record a backfill result (status + attempted) and gap-fill whitelisted
|
||
columns (COALESCE — never clobbers). status: 'ok'|'not_found'|'error'."""
|
||
spec = _BACKFILL.get(service, {}).get(kind)
|
||
if not spec:
|
||
return
|
||
tbl, sc, ac, _has = spec
|
||
allowed = _BACKFILL_COLS.get(service, set())
|
||
sets = [f"{sc}=?", f"{ac}=CURRENT_TIMESTAMP"]
|
||
params: list = [status]
|
||
for col, val in (columns or {}).items():
|
||
if val is None or col not in allowed:
|
||
continue
|
||
sets.append(f"{col}=COALESCE(NULLIF({col}, ''), ?)")
|
||
params.append(val)
|
||
params.append(item_id)
|
||
conn = self._get_connection()
|
||
try:
|
||
conn.execute(f"UPDATE {tbl} SET {', '.join(sets)} WHERE id=?", params)
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
|
||
def backfill_breakdown(self, service: str) -> dict:
|
||
kinds = _BACKFILL.get(service, {})
|
||
out = {}
|
||
conn = self._get_connection()
|
||
try:
|
||
for kind, (tbl, sc, _ac, has_id) in kinds.items():
|
||
base = f"FROM {tbl} WHERE {has_id}"
|
||
out[kind] = {
|
||
"matched": conn.execute(f"SELECT COUNT(*) {base} AND {sc}='ok'").fetchone()[0],
|
||
"not_found": conn.execute(f"SELECT COUNT(*) {base} AND {sc}='not_found'").fetchone()[0],
|
||
"errors": conn.execute(f"SELECT COUNT(*) {base} AND {sc}='error'").fetchone()[0],
|
||
"pending": conn.execute(f"SELECT COUNT(*) {base} AND {sc} IS NULL").fetchone()[0],
|
||
}
|
||
return out
|
||
finally:
|
||
conn.close()
|
||
|
||
# ── per-video YouTube enrichment (no-key: RYD votes + SponsorBlock) ────────
|
||
def youtube_enrich_next(self, status_col: str) -> dict | None:
|
||
"""Next cached YouTube video missing a per-video enrichment. status_col is
|
||
'ryd_status' or 'sb_status'. Distinct by youtube_id (a video shared across
|
||
playlists is enriched once). Returns {kind:'video', id, name, youtube_id}."""
|
||
if status_col not in ("ryd_status", "sb_status"):
|
||
return None
|
||
conn = self._get_connection()
|
||
try:
|
||
row = conn.execute(
|
||
"SELECT cv.youtube_id AS youtube_id, MIN(cv.title) AS title "
|
||
"FROM youtube_channel_videos cv "
|
||
"LEFT JOIN youtube_video_stats s ON s.youtube_id = cv.youtube_id "
|
||
f"WHERE s.youtube_id IS NULL OR s.{status_col} IS NULL "
|
||
"GROUP BY cv.youtube_id LIMIT 1").fetchone()
|
||
if not row:
|
||
return None
|
||
return {"kind": "video", "id": row["youtube_id"],
|
||
"name": row["title"], "youtube_id": row["youtube_id"]}
|
||
finally:
|
||
conn.close()
|
||
|
||
def apply_youtube_votes(self, youtube_id, like_count, dislike_count, status: str) -> None:
|
||
yid = str(youtube_id or "").strip()
|
||
if not yid:
|
||
return
|
||
conn = self._get_connection()
|
||
try:
|
||
conn.execute(
|
||
"INSERT INTO youtube_video_stats "
|
||
"(youtube_id, like_count, dislike_count, ryd_status, ryd_attempted) "
|
||
"VALUES (?,?,?,?,CURRENT_TIMESTAMP) ON CONFLICT(youtube_id) DO UPDATE SET "
|
||
"like_count=COALESCE(excluded.like_count, like_count), "
|
||
"dislike_count=COALESCE(excluded.dislike_count, dislike_count), "
|
||
"ryd_status=excluded.ryd_status, ryd_attempted=CURRENT_TIMESTAMP",
|
||
(yid, like_count, dislike_count, status))
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
|
||
def apply_youtube_segments(self, youtube_id, segments, status: str) -> None:
|
||
yid = str(youtube_id or "").strip()
|
||
if not yid:
|
||
return
|
||
conn = self._get_connection()
|
||
try:
|
||
conn.execute(
|
||
"INSERT INTO youtube_video_stats (youtube_id, sb_status, sb_attempted) "
|
||
"VALUES (?,?,CURRENT_TIMESTAMP) ON CONFLICT(youtube_id) DO UPDATE SET "
|
||
"sb_status=excluded.sb_status, sb_attempted=CURRENT_TIMESTAMP", (yid, status))
|
||
if segments:
|
||
conn.execute("DELETE FROM youtube_video_segments WHERE youtube_id=?", (yid,))
|
||
rows = [(yid, s.get("category"), s.get("start_sec"), s.get("end_sec"),
|
||
s.get("votes"), s.get("uuid"))
|
||
for s in segments if s.get("uuid") and s.get("category")]
|
||
if rows:
|
||
conn.executemany(
|
||
"INSERT OR IGNORE INTO youtube_video_segments "
|
||
"(youtube_id, category, start_sec, end_sec, votes, uuid) "
|
||
"VALUES (?,?,?,?,?,?)", rows)
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
|
||
def youtube_enrich_breakdown(self, status_col: str) -> dict:
|
||
if status_col not in ("ryd_status", "sb_status"):
|
||
return {}
|
||
conn = self._get_connection()
|
||
try:
|
||
total = conn.execute(
|
||
"SELECT COUNT(DISTINCT youtube_id) FROM youtube_channel_videos").fetchone()[0]
|
||
|
||
def c(st):
|
||
return conn.execute(
|
||
f"SELECT COUNT(*) FROM youtube_video_stats WHERE {status_col}=?", (st,)).fetchone()[0]
|
||
|
||
matched, nf, err = c("ok"), c("not_found"), c("error")
|
||
return {"video": {"matched": matched, "not_found": nf, "errors": err,
|
||
"pending": max(0, total - matched - nf - err)}}
|
||
finally:
|
||
conn.close()
|
||
|
||
def youtube_video_segments(self, youtube_id) -> list:
|
||
"""SponsorBlock segments for a video (detail UI / skip logic)."""
|
||
yid = str(youtube_id or "").strip()
|
||
if not yid:
|
||
return []
|
||
conn = self._get_connection()
|
||
try:
|
||
rows = conn.execute(
|
||
"SELECT category, start_sec, end_sec, votes FROM youtube_video_segments "
|
||
"WHERE youtube_id=? ORDER BY start_sec", (yid,)).fetchall()
|
||
return [dict(r) for r in rows]
|
||
finally:
|
||
conn.close()
|
||
|
||
def show_match_info(self, show_id: int) -> dict | None:
|
||
"""Title/year/tmdb_id for one show — for on-demand (lazy) art refresh."""
|
||
conn = self._get_connection()
|
||
try:
|
||
row = conn.execute("SELECT title, year, tmdb_id FROM shows WHERE id=?",
|
||
(show_id,)).fetchone()
|
||
return dict(row) if row else None
|
||
finally:
|
||
conn.close()
|
||
|
||
def movie_match_info(self, movie_id: int) -> dict | None:
|
||
"""Title/year/tmdb_id for one movie — for on-demand (lazy) refresh."""
|
||
conn = self._get_connection()
|
||
try:
|
||
row = conn.execute("SELECT title, year, tmdb_id FROM movies WHERE id=?",
|
||
(movie_id,)).fetchone()
|
||
return dict(row) if row else None
|
||
finally:
|
||
conn.close()
|
||
|
||
def library_id_for_tmdb(self, kind: str, tmdb_id, server_source=None) -> int | None:
|
||
"""The library row id for a TMDB id if it's owned on the active video
|
||
server (``server_source``), else None. Lets the search → detail flow link
|
||
owned titles to their real library detail — scoped per server so an item
|
||
owned only on the inactive server doesn't read as owned here."""
|
||
table = {"movie": "movies", "show": "shows"}.get(kind)
|
||
if not table or tmdb_id is None:
|
||
return None
|
||
try:
|
||
tmdb_id = int(tmdb_id)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
conn = self._get_connection()
|
||
try:
|
||
if server_source:
|
||
row = conn.execute(
|
||
f"SELECT id FROM {table} WHERE tmdb_id=? AND server_source=? LIMIT 1",
|
||
(tmdb_id, server_source)).fetchone()
|
||
else:
|
||
row = conn.execute(
|
||
f"SELECT id FROM {table} WHERE tmdb_id=? LIMIT 1", (tmdb_id,)).fetchone()
|
||
return row["id"] if row else None
|
||
except sqlite3.Error:
|
||
return None
|
||
finally:
|
||
conn.close()
|
||
|
||
def library_ids_for_tmdb(self, kind: str, tmdb_ids, server_source=None) -> dict:
|
||
"""{tmdb_id: library_row_id} for the owned subset of ``tmdb_ids`` on the
|
||
active server. Batched (chunked IN) so a whole Discover rail costs one
|
||
query per kind instead of one connection+query per item."""
|
||
table = {"movie": "movies", "show": "shows"}.get(kind)
|
||
out: dict = {}
|
||
if not table:
|
||
return out
|
||
ids = []
|
||
for x in (tmdb_ids or []):
|
||
try:
|
||
ids.append(int(x))
|
||
except (TypeError, ValueError):
|
||
pass
|
||
if not ids:
|
||
return out
|
||
conn = self._get_connection()
|
||
try:
|
||
for i in range(0, len(ids), 400): # stay under SQLite's variable cap
|
||
chunk = ids[i:i + 400]
|
||
ph = ",".join("?" * len(chunk))
|
||
sql = f"SELECT id, tmdb_id FROM {table} WHERE tmdb_id IN ({ph})"
|
||
args = list(chunk)
|
||
if server_source:
|
||
sql += " AND server_source=?"
|
||
args.append(server_source)
|
||
for row in conn.execute(sql, args):
|
||
out.setdefault(row["tmdb_id"], row["id"]) # first match wins
|
||
return out
|
||
except sqlite3.Error:
|
||
return out
|
||
finally:
|
||
conn.close()
|
||
|
||
def top_owned_genres(self, kind: str, server_source=None, limit: int = 6) -> list:
|
||
"""The user's most-owned genre names for movies/shows, busiest first —
|
||
drives Discover's personalized 'Because you like …' rails."""
|
||
if kind == "movie":
|
||
link, owner, tbl, owned = "movie_genres", "movie_id", "movies", "t.has_file=1"
|
||
elif kind == "show":
|
||
link, owner, tbl, owned = "show_genres", "show_id", "shows", "1=1" # any library show counts
|
||
else:
|
||
return []
|
||
sql = (f"SELECT g.name AS name, COUNT(*) AS c FROM {link} lt "
|
||
f"JOIN genres g ON g.id = lt.genre_id "
|
||
f"JOIN {tbl} t ON t.id = lt.{owner} WHERE {owned}")
|
||
args: list = []
|
||
if server_source:
|
||
sql += " AND t.server_source=?"
|
||
args.append(server_source)
|
||
sql += " GROUP BY g.name ORDER BY c DESC, g.name LIMIT ?"
|
||
args.append(int(limit))
|
||
conn = self._get_connection()
|
||
try:
|
||
return [r["name"] for r in conn.execute(sql, args)]
|
||
except sqlite3.Error:
|
||
return []
|
||
finally:
|
||
conn.close()
|
||
|
||
def random_owned_titles(self, limit: int = 2, server_source=None) -> list:
|
||
"""A few random owned titles (with a tmdb_id) to seed 'More like …' rails —
|
||
up to ``limit`` movies and ``limit`` shows."""
|
||
out = []
|
||
conn = self._get_connection()
|
||
try:
|
||
for kind, tbl, alias, owned in (("movie", "movies", "m", "m.has_file=1"),
|
||
("show", "shows", "s", "1=1")):
|
||
sql = (f"SELECT {alias}.id AS id, {alias}.tmdb_id AS tmdb_id, {alias}.title AS title "
|
||
f"FROM {tbl} {alias} WHERE {alias}.tmdb_id IS NOT NULL AND {owned}")
|
||
args: list = []
|
||
if server_source:
|
||
sql += f" AND {alias}.server_source=?"
|
||
args.append(server_source)
|
||
sql += " ORDER BY RANDOM() LIMIT ?"
|
||
args.append(int(limit))
|
||
for r in conn.execute(sql, args):
|
||
out.append({"kind": kind, "tmdb_id": r["tmdb_id"],
|
||
"title": r["title"], "library_id": r["id"]})
|
||
return out
|
||
except sqlite3.Error:
|
||
return out
|
||
finally:
|
||
conn.close()
|
||
|
||
def apply_ratings(self, kind: str, item_id: int, ratings: dict) -> None:
|
||
"""Store IMDb / RT / Metacritic scores (from OMDb) + mark ratings_synced.
|
||
Ratings are dynamic, so these overwrite (unlike gap-only metadata)."""
|
||
table = {"movie": "movies", "show": "shows"}.get(kind)
|
||
cols = {"imdb_rating", "rt_rating", "metacritic"}
|
||
sets, params = ["ratings_synced=1"], []
|
||
for c, v in (ratings or {}).items():
|
||
if c in cols and v is not None:
|
||
sets.append(f"{c}=?")
|
||
params.append(v)
|
||
if not table:
|
||
return
|
||
params.append(item_id)
|
||
conn = self._get_connection()
|
||
try:
|
||
conn.execute(f"UPDATE {table} SET {', '.join(sets)} WHERE id=?", params)
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
|
||
def ratings_next(self) -> dict | None:
|
||
"""Next library item that needs OMDb ratings (has an imdb_id, not synced).
|
||
Drives the OMDb worker's background pass. Returns {kind, id, title, imdb_id}."""
|
||
conn = self._get_connection()
|
||
try:
|
||
for kind, tbl in (("movie", "movies"), ("show", "shows")):
|
||
row = conn.execute(
|
||
f"SELECT id, title, imdb_id FROM {tbl} "
|
||
"WHERE imdb_id IS NOT NULL AND imdb_id<>'' AND ratings_synced=0 "
|
||
"ORDER BY id LIMIT 1").fetchone()
|
||
if row:
|
||
return {"kind": kind, "id": row["id"], "title": row["title"], "imdb_id": row["imdb_id"]}
|
||
return None
|
||
finally:
|
||
conn.close()
|
||
|
||
def mark_ratings_synced(self, kind: str, item_id: int) -> None:
|
||
table = {"movie": "movies", "show": "shows"}.get(kind)
|
||
if not table:
|
||
return
|
||
conn = self._get_connection()
|
||
try:
|
||
conn.execute(f"UPDATE {table} SET ratings_synced=1 WHERE id=?", (item_id,))
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
|
||
def mark_episodes_synced(self, show_id: int) -> None:
|
||
"""Flag that the show's FULL episode list has been pulled from metadata
|
||
(so the lazy on-view refresh doesn't re-cascade every visit)."""
|
||
conn = self._get_connection()
|
||
try:
|
||
conn.execute("UPDATE shows SET episodes_synced=1 WHERE id=?", (show_id,))
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
|
||
def episode_sync_next(self) -> dict | None:
|
||
"""A matched show (has tmdb_id) whose FULL episode list hasn't been pulled
|
||
yet — for the TMDB worker's background episode-sync pass, so library cards
|
||
show real owned/total without the user opening each one."""
|
||
conn = self._get_connection()
|
||
try:
|
||
row = conn.execute(
|
||
"SELECT id, title, year, tmdb_id FROM shows "
|
||
"WHERE tmdb_id IS NOT NULL AND episodes_synced=0 ORDER BY id LIMIT 1").fetchone()
|
||
return dict(row) if row else None
|
||
finally:
|
||
conn.close()
|
||
|
||
def episode_sync_pending_count(self) -> int:
|
||
conn = self._get_connection()
|
||
try:
|
||
return conn.execute(
|
||
"SELECT COUNT(*) FROM shows WHERE tmdb_id IS NOT NULL AND episodes_synced=0").fetchone()[0]
|
||
finally:
|
||
conn.close()
|
||
|
||
def _ratings_breakdown(self) -> dict:
|
||
"""OMDb 'coverage' breakdown: matched = ratings present, pending = has an
|
||
imdb_id but not fetched, not_found = fetched but OMDb had no rating."""
|
||
conn = self._get_connection()
|
||
out = {}
|
||
try:
|
||
for kind, tbl in (("movie", "movies"), ("show", "shows")):
|
||
def c(where, _tbl=tbl): # bind tbl per-iteration (ruff B023)
|
||
return conn.execute(f"SELECT COUNT(*) FROM {_tbl} WHERE {where}").fetchone()[0]
|
||
out[kind] = {
|
||
"matched": c("imdb_rating IS NOT NULL"),
|
||
"not_found": c("ratings_synced=1 AND imdb_rating IS NULL AND imdb_id IS NOT NULL"),
|
||
"errors": 0,
|
||
"pending": c("imdb_id IS NOT NULL AND imdb_id<>'' AND ratings_synced=0"),
|
||
}
|
||
return out
|
||
finally:
|
||
conn.close()
|
||
|
||
def show_season_numbers(self, show_id: int) -> list:
|
||
conn = self._get_connection()
|
||
try:
|
||
return [r["season_number"] for r in conn.execute(
|
||
"SELECT season_number FROM seasons WHERE show_id=? ORDER BY season_number",
|
||
(show_id,)).fetchall()]
|
||
finally:
|
||
conn.close()
|
||
|
||
def backfill_episodes(self, show_id: int, season_number: int, episodes: list,
|
||
season_overview: str | None = None, season_poster: str | None = None) -> int:
|
||
"""UPSERT a season's episodes from the metadata provider so the show's
|
||
FULL episode list is represented — owned episodes (from the server) keep
|
||
has_file=1, and episodes the server doesn't have are inserted as MISSING
|
||
(has_file=0). Existing rows get gap-only metadata fills (never clobbered);
|
||
the season row is created if it didn't exist (a fully-missing season).
|
||
Returns the number of episode rows touched."""
|
||
conn = self._get_connection()
|
||
touched = 0
|
||
try:
|
||
conn.execute("INSERT OR IGNORE INTO seasons (show_id, season_number) VALUES (?, ?)",
|
||
(show_id, season_number))
|
||
season_id = conn.execute("SELECT id FROM seasons WHERE show_id=? AND season_number=?",
|
||
(show_id, season_number)).fetchone()["id"]
|
||
if season_overview or season_poster:
|
||
conn.execute("UPDATE seasons SET overview=COALESCE(NULLIF(overview, ''), ?), "
|
||
"poster_url=COALESCE(NULLIF(poster_url, ''), ?) WHERE id=?",
|
||
(season_overview, season_poster, season_id))
|
||
for e in (episodes or []):
|
||
en = e.get("episode_number")
|
||
if en is None:
|
||
continue
|
||
row = conn.execute(
|
||
"SELECT id FROM episodes WHERE show_id=? AND season_number=? AND episode_number=?",
|
||
(show_id, season_number, en)).fetchone()
|
||
if row:
|
||
sets, params = [], []
|
||
for col in ("title", "still_url", "overview", "air_date", "rating", "runtime_minutes"):
|
||
if e.get(col) is None:
|
||
continue
|
||
sets.append(f"{col}=COALESCE(NULLIF({col}, ''), ?)")
|
||
params.append(e[col])
|
||
if sets:
|
||
params += [row["id"]]
|
||
conn.execute(f"UPDATE episodes SET {', '.join(sets)} WHERE id=?", params)
|
||
touched += 1
|
||
else:
|
||
conn.execute(
|
||
"INSERT INTO episodes (show_id, season_id, season_number, episode_number, title, "
|
||
"overview, air_date, runtime_minutes, still_url, rating, has_file) "
|
||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)",
|
||
(show_id, season_id, season_number, en, e.get("title"), e.get("overview"),
|
||
e.get("air_date"), e.get("runtime_minutes"), e.get("still_url"), e.get("rating")))
|
||
touched += 1
|
||
conn.commit()
|
||
return touched
|
||
finally:
|
||
conn.close()
|
||
|
||
def enrichment_unmatched(self, service: str, kind: str, status: str = "not_found",
|
||
search=None, limit: int = 50, offset: int = 0) -> dict:
|
||
if kind == "episode" and service == "tmdb":
|
||
return self._episodes_missing_art(search, limit, offset)
|
||
if service == "omdb" and kind in ("movie", "show"):
|
||
return self._ratings_unmatched(kind, search, limit, offset)
|
||
spec = _ENRICH.get(service, {}).get(kind)
|
||
if not spec:
|
||
return {"items": [], "total": 0}
|
||
tbl, _idc, sc, ac = spec
|
||
where, params = [], []
|
||
if status == "pending":
|
||
where.append(f"{sc} IS NULL")
|
||
elif status == "unmatched":
|
||
where.append(f"({sc} IS NULL OR {sc} IN ('not_found','error'))")
|
||
else:
|
||
where.append(f"{sc}='not_found'")
|
||
if search:
|
||
where.append("title LIKE ? COLLATE NOCASE")
|
||
params.append("%" + search + "%")
|
||
where_sql = " WHERE " + " AND ".join(where)
|
||
conn = self._get_connection()
|
||
try:
|
||
total = conn.execute(f"SELECT COUNT(*) FROM {tbl}{where_sql}", params).fetchone()[0]
|
||
rows = conn.execute(
|
||
f"SELECT id, title, year, {ac} AS last_attempted, "
|
||
f"(poster_url IS NOT NULL AND poster_url<>'') AS has_poster "
|
||
f"FROM {tbl}{where_sql} ORDER BY COALESCE(sort_title, title) COLLATE NOCASE "
|
||
f"LIMIT ? OFFSET ?", params + [limit, offset]).fetchall()
|
||
items = []
|
||
for r in rows:
|
||
d = dict(r)
|
||
d["has_poster"] = bool(d.get("has_poster"))
|
||
items.append(d)
|
||
return {"items": items, "total": total}
|
||
finally:
|
||
conn.close()
|
||
|
||
def _episodes_missing_art(self, search, limit, offset) -> dict:
|
||
"""Episodes still lacking a still image (for the manager's Episodes view).
|
||
Read-only: episode art is backfilled as a cascade, not a retry queue."""
|
||
where = ["(e.still_url IS NULL OR e.still_url='')"]
|
||
params: list = []
|
||
if search:
|
||
where.append("(e.title LIKE ? COLLATE NOCASE OR sh.title LIKE ? COLLATE NOCASE)")
|
||
params += ["%" + search + "%", "%" + search + "%"]
|
||
where_sql = " WHERE " + " AND ".join(where)
|
||
conn = self._get_connection()
|
||
try:
|
||
total = conn.execute(
|
||
f"SELECT COUNT(*) FROM episodes e JOIN shows sh ON sh.id=e.show_id{where_sql}",
|
||
params).fetchone()[0]
|
||
rows = conn.execute(
|
||
"SELECT e.id, sh.title || ' · S' || e.season_number || 'E' || e.episode_number "
|
||
"|| COALESCE(' · ' || e.title, '') AS title, e.air_date AS year, "
|
||
"0 AS has_poster, NULL AS last_attempted "
|
||
f"FROM episodes e JOIN shows sh ON sh.id=e.show_id{where_sql} "
|
||
"ORDER BY sh.sort_title, e.season_number, e.episode_number LIMIT ? OFFSET ?",
|
||
params + [limit, offset]).fetchall()
|
||
return {"items": [dict(r) | {"has_poster": False} for r in rows], "total": total}
|
||
finally:
|
||
conn.close()
|
||
|
||
def _ratings_unmatched(self, kind: str, search, limit: int, offset: int) -> dict:
|
||
tbl = {"movie": "movies", "show": "shows"}[kind]
|
||
where = ["imdb_rating IS NULL", "imdb_id IS NOT NULL", "imdb_id<>''"]
|
||
params: list = []
|
||
if search:
|
||
where.append("title LIKE ? COLLATE NOCASE")
|
||
params.append("%" + search + "%")
|
||
where_sql = " WHERE " + " AND ".join(where)
|
||
conn = self._get_connection()
|
||
try:
|
||
total = conn.execute(f"SELECT COUNT(*) FROM {tbl}{where_sql}", params).fetchone()[0]
|
||
rows = conn.execute(
|
||
f"SELECT id, title, year, (poster_url IS NOT NULL AND poster_url<>'') AS has_poster "
|
||
f"FROM {tbl}{where_sql} ORDER BY COALESCE(sort_title, title) COLLATE NOCASE "
|
||
"LIMIT ? OFFSET ?", params + [limit, offset]).fetchall()
|
||
return {"items": [dict(r) | {"has_poster": bool(r["has_poster"])} for r in rows], "total": total}
|
||
finally:
|
||
conn.close()
|
||
|
||
def enrichment_retry(self, service: str, kind: str, scope: str = "failed", item_id=None) -> int:
|
||
"""Re-queue items by resetting status/last_attempted to NULL."""
|
||
if service == "omdb":
|
||
tbl = {"movie": "movies", "show": "shows"}.get(kind)
|
||
if not tbl:
|
||
return 0
|
||
conn = self._get_connection()
|
||
try:
|
||
if scope == "item" and item_id is not None:
|
||
cur = conn.execute(f"UPDATE {tbl} SET ratings_synced=0 WHERE id=?", (item_id,))
|
||
else:
|
||
cur = conn.execute(f"UPDATE {tbl} SET ratings_synced=0 WHERE imdb_rating IS NULL")
|
||
conn.commit()
|
||
return cur.rowcount
|
||
finally:
|
||
conn.close()
|
||
if service in ("ryd", "sponsorblock"):
|
||
col = "ryd_status" if service == "ryd" else "sb_status"
|
||
att = "ryd_attempted" if service == "ryd" else "sb_attempted"
|
||
conn = self._get_connection()
|
||
try:
|
||
if scope == "item" and item_id is not None:
|
||
cur = conn.execute(
|
||
f"UPDATE youtube_video_stats SET {col}=NULL, {att}=NULL WHERE youtube_id=?",
|
||
(str(item_id),))
|
||
else:
|
||
cur = conn.execute(
|
||
f"UPDATE youtube_video_stats SET {col}=NULL, {att}=NULL "
|
||
f"WHERE {col} IN ('not_found','error')")
|
||
conn.commit()
|
||
return cur.rowcount
|
||
finally:
|
||
conn.close()
|
||
if service in _BACKFILL:
|
||
spec = _BACKFILL[service].get(kind)
|
||
if not spec:
|
||
return 0
|
||
tbl, sc, ac, _has = spec
|
||
conn = self._get_connection()
|
||
try:
|
||
if scope == "item" and item_id is not None:
|
||
cur = conn.execute(f"UPDATE {tbl} SET {sc}=NULL, {ac}=NULL WHERE id=?", (item_id,))
|
||
else:
|
||
cur = conn.execute(
|
||
f"UPDATE {tbl} SET {sc}=NULL, {ac}=NULL WHERE {sc} IN ('not_found','error')")
|
||
conn.commit()
|
||
return cur.rowcount
|
||
finally:
|
||
conn.close()
|
||
spec = _ENRICH.get(service, {}).get(kind)
|
||
if not spec:
|
||
return 0
|
||
tbl, _idc, sc, ac = spec
|
||
conn = self._get_connection()
|
||
try:
|
||
if scope == "item" and item_id is not None:
|
||
cur = conn.execute(f"UPDATE {tbl} SET {sc}=NULL, {ac}=NULL WHERE id=?", (item_id,))
|
||
else:
|
||
cur = conn.execute(
|
||
f"UPDATE {tbl} SET {sc}=NULL, {ac}=NULL WHERE {sc} IN ('not_found','error')")
|
||
conn.commit()
|
||
return cur.rowcount
|
||
finally:
|
||
conn.close()
|
||
|
||
def requeue_shows_for_airtime(self) -> int:
|
||
"""One-time backfill: re-queue TVDB enrichment for shows that have a
|
||
tvdb_id but no air time yet, so the worker re-fetches `airsTime`. Only
|
||
touches shows missing the time — idempotent, fills in the background."""
|
||
conn = self._get_connection()
|
||
try:
|
||
cur = conn.execute(
|
||
"UPDATE shows SET tvdb_match_status=NULL, tvdb_last_attempted=NULL "
|
||
"WHERE tvdb_id IS NOT NULL AND (airs_time IS NULL OR airs_time='')")
|
||
conn.commit()
|
||
return cur.rowcount
|
||
finally:
|
||
conn.close()
|
||
|
||
@property
|
||
def schema_version(self) -> int:
|
||
conn = self._get_connection()
|
||
try:
|
||
return int(conn.execute("PRAGMA user_version").fetchone()[0])
|
||
finally:
|
||
conn.close()
|
||
|
||
# ── video_settings KV (temporary home until the settings.db move) ────────
|
||
def get_setting(self, key: str, default=None):
|
||
conn = self._get_connection()
|
||
try:
|
||
row = conn.execute(
|
||
"SELECT value FROM video_settings WHERE key = ?", (key,)
|
||
).fetchone()
|
||
return row["value"] if row is not None else default
|
||
finally:
|
||
conn.close()
|
||
|
||
def set_setting(self, key: str, value: str) -> None:
|
||
conn = self._get_connection()
|
||
try:
|
||
conn.execute(
|
||
"INSERT INTO video_settings(key, value, updated_at) "
|
||
"VALUES (?, ?, CURRENT_TIMESTAMP) "
|
||
"ON CONFLICT(key) DO UPDATE SET value = excluded.value, "
|
||
"updated_at = CURRENT_TIMESTAMP",
|
||
(key, value),
|
||
)
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
|
||
# ── library mapping (which server library is Movies / TV) ─────────────────
|
||
def get_library_selection(self, server: str) -> dict:
|
||
return {
|
||
"movies": self.get_setting(server + ".movies_library"),
|
||
"tv": self.get_setting(server + ".tv_library"),
|
||
}
|
||
|
||
def set_library_selection(self, server: str, movies, tv) -> None:
|
||
self.set_setting(server + ".movies_library", movies or "")
|
||
self.set_setting(server + ".tv_library", tv or "")
|
||
|
||
# ── dashboard ─────────────────────────────────────────────────────────────
|
||
def dashboard_stats(self, server_source=None) -> dict:
|
||
"""Live counts for the video dashboard, straight from video.db. Library
|
||
counts are scoped to the active video server (``server_source``) so Plex
|
||
and Jellyfin never commingle.
|
||
|
||
Shape is stable so the frontend can map it directly; with an empty
|
||
database every number is a real 0 (not a stub).
|
||
"""
|
||
# server_source given → scope to that server; None → all servers.
|
||
mw = " WHERE server_source=?" if server_source else ""
|
||
sw = " WHERE s.server_source=?" if server_source else ""
|
||
sv = (server_source,) if server_source else ()
|
||
size_sql = ("SELECT COALESCE(SUM(size_bytes), 0) FROM media_files mf "
|
||
"WHERE mf.movie_id IN (SELECT id FROM movies WHERE server_source=?) "
|
||
"OR mf.episode_id IN (SELECT e.id FROM episodes e JOIN shows s ON s.id=e.show_id "
|
||
"WHERE s.server_source=?)") if server_source else \
|
||
"SELECT COALESCE(SUM(size_bytes), 0) FROM media_files"
|
||
conn = self._get_connection()
|
||
try:
|
||
def scalar(sql: str, params=()):
|
||
return conn.execute(sql, params).fetchone()[0]
|
||
|
||
return {
|
||
"library": {
|
||
"movies": scalar("SELECT COUNT(*) FROM movies" + mw, sv),
|
||
"shows": scalar("SELECT COUNT(*) FROM shows" + mw, sv),
|
||
"episodes": scalar(
|
||
"SELECT COUNT(*) FROM episodes e JOIN shows s ON s.id=e.show_id" + sw, sv),
|
||
"size_bytes": scalar(size_sql, (sv + sv) if server_source else ()),
|
||
},
|
||
"downloads": {
|
||
"active": scalar(
|
||
"SELECT COUNT(*) FROM downloads "
|
||
"WHERE status IN ('queued','downloading','importing')"),
|
||
"finished": scalar("SELECT COUNT(*) FROM downloads WHERE status = 'completed'"),
|
||
"speed_bps": scalar(
|
||
"SELECT COALESCE(SUM(download_speed_bps), 0) FROM downloads "
|
||
"WHERE status = 'downloading'"),
|
||
},
|
||
# Curated watchlist (explicit follows + actively-airing library
|
||
# shows), NOT the old monitored-based v_watchlist view.
|
||
"watchlist": self.watchlist_counts(server_source=server_source)["total"],
|
||
# Curated wishlist (movies + wanted episodes), NOT the old
|
||
# v_wishlist view that auto-listed every missing item.
|
||
"wishlist": self.wishlist_counts()["total"],
|
||
}
|
||
finally:
|
||
conn.close()
|
||
|
||
# ── scan upserts (server is the source of truth) ──────────────────────────
|
||
# The scanner passes normalized, server-agnostic dicts (a Plex/Jellyfin
|
||
# adapter produces them) so this layer never touches a media-server SDK.
|
||
@staticmethod
|
||
def _set_media_file(conn, owner_col: str, owner_id: int, file: dict | None) -> None:
|
||
"""Replace the media_files row(s) for one owner. owner_col is internal
|
||
('movie_id'|'episode_id'|'video_id'), never user input."""
|
||
conn.execute(f"DELETE FROM media_files WHERE {owner_col} = ?", (owner_id,))
|
||
if not file:
|
||
return
|
||
conn.execute(
|
||
f"INSERT INTO media_files ({owner_col}, relative_path, size_bytes, resolution, "
|
||
"video_codec, audio_codec, release_source, quality, runtime_seconds) "
|
||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||
(owner_id,
|
||
file.get("relative_path") or file.get("path") or "",
|
||
file.get("size_bytes"), file.get("resolution"), file.get("video_codec"),
|
||
file.get("audio_codec"), file.get("release_source"), file.get("quality"),
|
||
file.get("runtime_seconds")),
|
||
)
|
||
|
||
@staticmethod
|
||
def _set_genres(conn, link_table: str, owner_col: str, owner_id: int, names) -> None:
|
||
"""Replace the genre links for one owner (normalised; dedup names in the
|
||
shared genres table). owner_col/link_table are internal, never user input."""
|
||
conn.execute(f"DELETE FROM {link_table} WHERE {owner_col}=?", (owner_id,))
|
||
for raw in (names or []):
|
||
name = (raw or "").strip()
|
||
if not name:
|
||
continue
|
||
conn.execute("INSERT OR IGNORE INTO genres (name) VALUES (?)", (name,))
|
||
gid = conn.execute("SELECT id FROM genres WHERE name=? COLLATE NOCASE", (name,)).fetchone()["id"]
|
||
conn.execute(f"INSERT OR IGNORE INTO {link_table} ({owner_col}, genre_id) VALUES (?, ?)",
|
||
(owner_id, gid))
|
||
|
||
@staticmethod
|
||
def _resilient_upsert(conn, table: str, base: dict, id_cols: dict) -> None:
|
||
"""INSERT…ON CONFLICT(server_source, server_id) for a movie/show row.
|
||
|
||
Resilient to a LEGACY UNIQUE on tmdb_id/tvdb_id/imdb_id (old DBs created
|
||
before those were made non-unique — SQLite can't drop an inline UNIQUE):
|
||
on IntegrityError we retry WITHOUT the id columns, so the row is still
|
||
stored (same film in >1 library) instead of being dropped by the scan.
|
||
``base`` holds the always-written cols; ``id_cols`` the droppable ids."""
|
||
def run(include_ids):
|
||
cols = list(base.keys()) + (list(id_cols.keys()) if include_ids else [])
|
||
vals = list(base.values()) + (list(id_cols.values()) if include_ids else [])
|
||
updates = [c for c in cols if c not in ("server_source", "server_id")]
|
||
set_clause = ", ".join(f"{c}=excluded.{c}" for c in updates) + ", updated_at=CURRENT_TIMESTAMP"
|
||
sql = (f"INSERT INTO {table} ({', '.join(cols)}, updated_at) "
|
||
f"VALUES ({', '.join(['?'] * len(cols))}, CURRENT_TIMESTAMP) "
|
||
f"ON CONFLICT(server_source, server_id) DO UPDATE SET {set_clause}")
|
||
conn.execute(sql, vals)
|
||
try:
|
||
run(True)
|
||
except sqlite3.IntegrityError:
|
||
conn.rollback() # legacy UNIQUE on an id — keep the row, drop the id
|
||
run(False)
|
||
|
||
@staticmethod
|
||
def _set_credits(conn, owner_col: str, owner_id: int, cast, crew) -> None:
|
||
"""Replace the cast+crew for one owner (deduped people in the shared
|
||
people table). owner_col is internal ('movie_id'|'show_id')."""
|
||
conn.execute(f"DELETE FROM credits WHERE {owner_col}=?", (owner_id,))
|
||
|
||
def person_id(p):
|
||
tid = p.get("tmdb_id")
|
||
if tid is not None:
|
||
conn.execute("INSERT OR IGNORE INTO people (name, tmdb_id, photo_url) VALUES (?, ?, ?)",
|
||
(p["name"], tid, p.get("photo_url")))
|
||
row = conn.execute("SELECT id FROM people WHERE tmdb_id=?", (tid,)).fetchone()
|
||
else:
|
||
conn.execute("INSERT INTO people (name, photo_url) VALUES (?, ?)",
|
||
(p["name"], p.get("photo_url")))
|
||
row = conn.execute("SELECT last_insert_rowid() AS id").fetchone()
|
||
pid = row["id"] if row else None
|
||
if pid and p.get("photo_url"):
|
||
conn.execute("UPDATE people SET photo_url=COALESCE(NULLIF(photo_url, ''), ?) WHERE id=?",
|
||
(p["photo_url"], pid))
|
||
return pid
|
||
|
||
def add(group, department, job_default):
|
||
for i, c in enumerate(group or []):
|
||
if not c.get("name"):
|
||
continue
|
||
pid = person_id(c)
|
||
if not pid:
|
||
continue
|
||
conn.execute(
|
||
f"INSERT INTO credits (person_id, {owner_col}, department, job, character, sort_order) "
|
||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||
(pid, owner_id, department, c.get("job") or job_default, c.get("character"), i))
|
||
add(cast, "cast", "Actor")
|
||
add(crew, "crew", None)
|
||
|
||
@staticmethod
|
||
def _credits_for(conn, owner_col: str, owner_id: int, cast_limit: int = 18) -> dict:
|
||
rows = conn.execute(
|
||
"SELECT p.name, p.photo_url, p.tmdb_id, c.department, c.job, c.character "
|
||
f"FROM credits c JOIN people p ON p.id = c.person_id WHERE c.{owner_col}=? "
|
||
"ORDER BY c.department, c.sort_order", (owner_id,)).fetchall()
|
||
cast = [{"name": r["name"], "character": r["character"], "photo": r["photo_url"],
|
||
"tmdb_id": r["tmdb_id"]}
|
||
for r in rows if r["department"] == "cast"][:cast_limit]
|
||
crew = [{"name": r["name"], "job": r["job"], "tmdb_id": r["tmdb_id"]}
|
||
for r in rows if r["department"] == "crew"]
|
||
return {"cast": cast, "crew": crew}
|
||
|
||
def upsert_movie(self, server_source: str, item: dict) -> int:
|
||
"""Insert/update one movie (keyed on server id) and its file. Returns row id."""
|
||
conn = self._get_connection()
|
||
try:
|
||
self._resilient_upsert(conn, "movies", {
|
||
"server_source": server_source, "server_id": item["server_id"],
|
||
"title": item.get("title"), "sort_title": _sort_title(item.get("title")),
|
||
"year": item.get("year"), "overview": item.get("overview"),
|
||
"runtime_minutes": item.get("runtime_minutes"), "content_rating": item.get("content_rating"),
|
||
"studio": item.get("studio"), "tagline": item.get("tagline"),
|
||
"rating": item.get("rating"), "rating_critic": item.get("rating_critic"),
|
||
"poster_url": item.get("poster_url"), "has_file": 1 if item.get("file") else 0,
|
||
}, {"tmdb_id": item.get("tmdb_id"), "imdb_id": item.get("imdb_id")})
|
||
movie_id = conn.execute(
|
||
"SELECT id FROM movies WHERE server_source=? AND server_id=?",
|
||
(server_source, item["server_id"]),
|
||
).fetchone()["id"]
|
||
self._set_media_file(conn, "movie_id", movie_id, item.get("file"))
|
||
self._set_genres(conn, "movie_genres", "movie_id", movie_id, item.get("genres"))
|
||
conn.commit()
|
||
return movie_id
|
||
finally:
|
||
conn.close()
|
||
|
||
def upsert_show_tree(self, server_source: str, item: dict) -> int:
|
||
"""Insert/update a show with its seasons + episodes (and files) in one
|
||
transaction. Episodes/seasons no longer present on the server for this
|
||
show are pruned. Returns the show row id."""
|
||
conn = self._get_connection()
|
||
try:
|
||
self._resilient_upsert(conn, "shows", {
|
||
"server_source": server_source, "server_id": item["server_id"],
|
||
"title": item.get("title"), "sort_title": _sort_title(item.get("title")),
|
||
"year": item.get("year"), "overview": item.get("overview"),
|
||
"status": item.get("status"), "network": item.get("network"),
|
||
"runtime_minutes": item.get("runtime_minutes"), "content_rating": item.get("content_rating"),
|
||
"tagline": item.get("tagline"), "rating": item.get("rating"),
|
||
"first_air_date": item.get("first_air_date"), "last_air_date": item.get("last_air_date"),
|
||
"poster_url": item.get("poster_url"),
|
||
}, {"tvdb_id": item.get("tvdb_id"), "tmdb_id": item.get("tmdb_id"), "imdb_id": item.get("imdb_id")})
|
||
show_id = conn.execute(
|
||
"SELECT id FROM shows WHERE server_source=? AND server_id=?",
|
||
(server_source, item["server_id"]),
|
||
).fetchone()["id"]
|
||
self._set_genres(conn, "show_genres", "show_id", show_id, item.get("genres"))
|
||
|
||
seen_seasons: set[int] = set()
|
||
seen_eps: set[tuple[int, int]] = set()
|
||
for season in item.get("seasons", []):
|
||
snum = season["season_number"]
|
||
seen_seasons.add(snum)
|
||
conn.execute(
|
||
"INSERT INTO seasons (show_id, server_id, season_number, title, overview, poster_url) "
|
||
"VALUES (?, ?, ?, ?, ?, ?) "
|
||
"ON CONFLICT(show_id, season_number) DO UPDATE SET "
|
||
"server_id=excluded.server_id, title=excluded.title, "
|
||
"overview=excluded.overview, poster_url=excluded.poster_url",
|
||
(show_id, season.get("server_id"), snum, season.get("title"),
|
||
season.get("overview"), season.get("poster_url")),
|
||
)
|
||
season_id = conn.execute(
|
||
"SELECT id FROM seasons WHERE show_id=? AND season_number=?", (show_id, snum)
|
||
).fetchone()["id"]
|
||
|
||
for ep in season.get("episodes", []):
|
||
enum = ep.get("episode_number")
|
||
if enum is None or snum is None:
|
||
continue # can't key an episode without season+episode numbers
|
||
seen_eps.add((snum, enum))
|
||
conn.execute(
|
||
"INSERT INTO episodes (show_id, season_id, server_source, server_id, "
|
||
"season_number, episode_number, title, overview, air_date, "
|
||
"runtime_minutes, still_url, rating, tvdb_id, has_file) "
|
||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
||
"ON CONFLICT(show_id, season_number, episode_number) DO UPDATE SET "
|
||
"season_id=excluded.season_id, server_source=excluded.server_source, "
|
||
"server_id=excluded.server_id, title=excluded.title, "
|
||
"overview=excluded.overview, air_date=excluded.air_date, "
|
||
"runtime_minutes=excluded.runtime_minutes, still_url=excluded.still_url, "
|
||
"rating=excluded.rating, tvdb_id=excluded.tvdb_id, has_file=excluded.has_file",
|
||
(show_id, season_id, server_source, ep.get("server_id"), snum, enum,
|
||
ep.get("title"), ep.get("overview"), ep.get("air_date"),
|
||
ep.get("runtime_minutes"), ep.get("still_url"), ep.get("rating"),
|
||
ep.get("tvdb_id"), 1 if ep.get("file") else 0),
|
||
)
|
||
ep_id = conn.execute(
|
||
"SELECT id FROM episodes WHERE show_id=? AND season_number=? AND episode_number=?",
|
||
(show_id, snum, enum),
|
||
).fetchone()["id"]
|
||
self._set_media_file(conn, "episode_id", ep_id, ep.get("file"))
|
||
|
||
# Prune only SERVER-originated rows that vanished (server_id set) — the
|
||
# full episode/season list now includes enrichment-added MISSING items
|
||
# (server_id NULL), which the scan must never remove.
|
||
for row in conn.execute(
|
||
"SELECT season_number, episode_number FROM episodes "
|
||
"WHERE show_id=? AND server_id IS NOT NULL", (show_id,)
|
||
).fetchall():
|
||
if (row["season_number"], row["episode_number"]) not in seen_eps:
|
||
conn.execute(
|
||
"DELETE FROM episodes WHERE show_id=? AND season_number=? AND episode_number=?",
|
||
(show_id, row["season_number"], row["episode_number"]),
|
||
)
|
||
for row in conn.execute(
|
||
"SELECT season_number FROM seasons WHERE show_id=? AND server_id IS NOT NULL", (show_id,)
|
||
).fetchall():
|
||
if row["season_number"] not in seen_seasons:
|
||
conn.execute("DELETE FROM seasons WHERE show_id=? AND season_number=?",
|
||
(show_id, row["season_number"]))
|
||
conn.commit()
|
||
return show_id
|
||
finally:
|
||
conn.close()
|
||
|
||
def server_ids(self, table: str, server_source: str) -> set:
|
||
"""All server_ids already stored for a server (for incremental early-stop)."""
|
||
if table not in ("movies", "shows"):
|
||
return set()
|
||
conn = self._get_connection()
|
||
try:
|
||
return {str(r[0]) for r in conn.execute(
|
||
f"SELECT server_id FROM {table} WHERE server_source=?", (server_source,)).fetchall()}
|
||
finally:
|
||
conn.close()
|
||
|
||
def table_count(self, table: str) -> int:
|
||
if table not in ("movies", "shows", "episodes"):
|
||
return 0
|
||
conn = self._get_connection()
|
||
try:
|
||
return conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
|
||
finally:
|
||
conn.close()
|
||
|
||
def prune_missing(self, table: str, server_source: str, seen_ids) -> int:
|
||
"""Delete top-level rows for a server that the scan no longer saw.
|
||
``table`` is internal ('movies'|'shows'); cascades clean children.
|
||
|
||
Safety (mirrors music's deep scan): if removal would wipe >50% of a
|
||
>100-row library, assume a partial server failure and skip it."""
|
||
if table not in ("movies", "shows"):
|
||
raise ValueError(f"prune_missing: unexpected table {table!r}")
|
||
seen = {str(s) for s in seen_ids}
|
||
conn = self._get_connection()
|
||
try:
|
||
existing = [r["server_id"] for r in conn.execute(
|
||
f"SELECT server_id FROM {table} WHERE server_source=?", (server_source,)
|
||
).fetchall()]
|
||
stale = [sid for sid in existing if str(sid) not in seen]
|
||
if len(stale) > len(existing) * 0.5 and len(existing) > 100:
|
||
logger.warning(
|
||
"Video deep scan: %d/%d %s stale (>50%%) — skipping removal (likely a "
|
||
"partial server response)", len(stale), len(existing), table)
|
||
return 0
|
||
for sid in stale:
|
||
conn.execute(f"DELETE FROM {table} WHERE server_source=? AND server_id=?",
|
||
(server_source, sid))
|
||
conn.commit()
|
||
return len(stale)
|
||
finally:
|
||
conn.close()
|
||
|
||
# ── library listing ───────────────────────────────────────────────────────
|
||
@staticmethod
|
||
def _with_poster_flag(row: dict) -> dict:
|
||
# Don't leak the raw server thumb path; just say whether a poster exists
|
||
# (the frontend hits /api/video/poster/<kind>/<id> when true).
|
||
d = dict(row)
|
||
d["has_poster"] = bool(d.pop("poster_url", None))
|
||
return d
|
||
|
||
def list_movies(self) -> list[dict]:
|
||
conn = self._get_connection()
|
||
try:
|
||
rows = conn.execute(
|
||
"SELECT id, title, year, poster_url, has_file, monitored "
|
||
"FROM movies ORDER BY COALESCE(sort_title, title) COLLATE NOCASE, title"
|
||
).fetchall()
|
||
return [self._with_poster_flag(r) for r in rows]
|
||
finally:
|
||
conn.close()
|
||
|
||
def list_shows(self) -> list[dict]:
|
||
conn = self._get_connection()
|
||
try:
|
||
rows = conn.execute(
|
||
"SELECT s.id, s.title, s.year, s.poster_url, s.monitored, "
|
||
"(SELECT COUNT(*) FROM episodes e WHERE e.show_id = s.id) AS episode_count, "
|
||
"(SELECT COUNT(*) FROM episodes e WHERE e.show_id = s.id AND e.has_file = 1) AS owned_count "
|
||
"FROM shows s ORDER BY COALESCE(s.sort_title, s.title) COLLATE NOCASE, s.title"
|
||
).fetchall()
|
||
return [self._with_poster_flag(r) for r in rows]
|
||
finally:
|
||
conn.close()
|
||
|
||
def calendar_upcoming(self, start_date: str, end_date: str, server_source=None) -> list[dict]:
|
||
"""Episodes airing in [start_date, end_date] (ISO) for shows on the active
|
||
video server (``server_source``) — the Calendar feed. Scoped to one server
|
||
so Plex and Jellyfin never commingle. Each row carries owned/missing
|
||
(has_file), a still flag, and show network/airs_time/year for the card."""
|
||
# server_source given → that server only; None → all owned shows.
|
||
if server_source:
|
||
srv_where, pre = "s.server_source = ?", [server_source]
|
||
else:
|
||
srv_where, pre = "s.server_source IS NOT NULL", []
|
||
conn = self._get_connection()
|
||
try:
|
||
rows = conn.execute(
|
||
"SELECT e.id, e.show_id, e.season_number, e.episode_number, e.title, "
|
||
"e.overview, e.air_date, e.runtime_minutes, e.rating, e.has_file, e.monitored, "
|
||
"(e.still_url IS NOT NULL AND e.still_url<>'') AS has_still, "
|
||
"s.tmdb_id AS show_tmdb_id, "
|
||
"s.title AS show_title, s.network, s.airs_time, s.year AS show_year, s.status AS show_status, "
|
||
"(s.poster_url IS NOT NULL AND s.poster_url<>'') AS show_has_poster, "
|
||
"(s.backdrop_url IS NOT NULL AND s.backdrop_url<>'') AS show_has_backdrop "
|
||
"FROM episodes e JOIN shows s ON s.id = e.show_id "
|
||
"WHERE " + srv_where + " "
|
||
"AND e.air_date IS NOT NULL AND e.air_date >= ? AND e.air_date <= ? "
|
||
"ORDER BY e.air_date, COALESCE(s.sort_title, s.title) COLLATE NOCASE, "
|
||
"e.season_number, e.episode_number",
|
||
pre + [start_date, end_date]).fetchall()
|
||
return [dict(r) for r in rows]
|
||
finally:
|
||
conn.close()
|
||
|
||
def get_poster_ref(self, kind: str, item_id: int) -> dict | None:
|
||
"""Server source/id/poster path for one movie or show, for the poster proxy."""
|
||
return self.get_art_ref(kind, item_id, "poster")
|
||
|
||
def get_art_ref(self, kind: str, item_id: int, art: str = "poster") -> dict | None:
|
||
"""Server source/id + artwork path for one movie/show/season, for the image
|
||
proxy. ``art`` is 'poster' or 'backdrop'. Returns the path under
|
||
'poster_url' so the proxy is artwork-agnostic."""
|
||
conn = self._get_connection()
|
||
try:
|
||
if kind == "season":
|
||
if art != "poster":
|
||
return None
|
||
# Seasons don't carry server_source — inherit the parent show's.
|
||
row = conn.execute(
|
||
"SELECT sh.server_source, se.server_id, se.poster_url "
|
||
"FROM seasons se JOIN shows sh ON sh.id = se.show_id WHERE se.id=?",
|
||
(item_id,)).fetchone()
|
||
return dict(row) if row else None
|
||
if kind == "episode":
|
||
if art != "poster":
|
||
return None
|
||
# Episode still; episodes carry their own server_source + the still path.
|
||
row = conn.execute(
|
||
"SELECT server_source, server_id, still_url AS poster_url "
|
||
"FROM episodes WHERE id=?", (item_id,)).fetchone()
|
||
return dict(row) if row else None
|
||
table = {"movie": "movies", "show": "shows"}.get(kind)
|
||
col = {"poster": "poster_url", "backdrop": "backdrop_url"}.get(art)
|
||
if not table or not col:
|
||
return None
|
||
row = conn.execute(
|
||
f"SELECT server_source, server_id, {col} AS poster_url FROM {table} WHERE id=?",
|
||
(item_id,)).fetchone()
|
||
return dict(row) if row else None
|
||
finally:
|
||
conn.close()
|
||
|
||
# ── detail payloads (drill-in pages) ──────────────────────────────────────
|
||
@staticmethod
|
||
def _genres_for(conn, link_table: str, owner_col: str, owner_id: int) -> list:
|
||
rows = conn.execute(
|
||
f"SELECT g.name FROM {link_table} lt JOIN genres g ON g.id = lt.genre_id "
|
||
f"WHERE lt.{owner_col}=? ORDER BY g.name", (owner_id,)).fetchall()
|
||
return [r["name"] for r in rows]
|
||
|
||
def show_detail(self, show_id: int) -> dict | None:
|
||
"""Full TV-show detail: the show + its seasons → episodes tree, with
|
||
owned/total roll-ups. Drives the (isolated) video show-detail page."""
|
||
conn = self._get_connection()
|
||
try:
|
||
show = conn.execute("SELECT * FROM shows WHERE id=?", (show_id,)).fetchone()
|
||
if not show:
|
||
return None
|
||
genres = self._genres_for(conn, "show_genres", "show_id", show_id)
|
||
credits = self._credits_for(conn, "show_id", show_id)
|
||
seasons = conn.execute(
|
||
"SELECT id, season_number, title, overview, "
|
||
"(poster_url IS NOT NULL AND poster_url<>'') AS has_poster "
|
||
"FROM seasons WHERE show_id=? ORDER BY season_number", (show_id,)).fetchall()
|
||
eps = conn.execute(
|
||
"SELECT id, season_number, episode_number, title, overview, air_date, "
|
||
"runtime_minutes, rating, monitored, has_file, "
|
||
"(still_url IS NOT NULL AND still_url<>'') AS has_still FROM episodes WHERE show_id=? "
|
||
"ORDER BY season_number, episode_number", (show_id,)).fetchall()
|
||
finally:
|
||
conn.close()
|
||
|
||
by_season: dict = {}
|
||
for e in eps:
|
||
by_season.setdefault(e["season_number"], []).append({
|
||
"id": e["id"], "episode_number": e["episode_number"],
|
||
"title": e["title"], "overview": e["overview"], "air_date": e["air_date"],
|
||
"runtime_minutes": e["runtime_minutes"], "rating": e["rating"],
|
||
"has_still": bool(e["has_still"]),
|
||
"monitored": bool(e["monitored"]), "owned": bool(e["has_file"]),
|
||
})
|
||
|
||
# Seasons declared in the seasons table, plus any season numbers that only
|
||
# exist via episodes (defensive — a show with episodes but no season row).
|
||
season_nums = [s["season_number"] for s in seasons]
|
||
season_meta = {s["season_number"]: s for s in seasons}
|
||
for num in by_season:
|
||
if num not in season_meta:
|
||
season_nums.append(num)
|
||
out_seasons = []
|
||
for num in sorted(set(season_nums)):
|
||
ep_list = by_season.get(num, [])
|
||
owned = sum(1 for e in ep_list if e["owned"])
|
||
meta = season_meta.get(num)
|
||
out_seasons.append({
|
||
"id": meta["id"] if meta else None, # needed for the season poster proxy
|
||
"season_number": num,
|
||
"title": (meta["title"] if meta else None) or (
|
||
"Specials" if num == 0 else "Season %d" % num),
|
||
"overview": meta["overview"] if meta else None,
|
||
"has_poster": bool(meta["has_poster"]) if meta else False,
|
||
"episode_total": len(ep_list),
|
||
"episode_owned": owned,
|
||
"episodes": ep_list,
|
||
})
|
||
|
||
total = len(eps)
|
||
owned_total = sum(1 for e in eps if e["has_file"])
|
||
return {
|
||
"kind": "show", "id": show["id"], "title": show["title"], "year": show["year"],
|
||
"overview": show["overview"], "status": show["status"], "network": show["network"],
|
||
"content_rating": show["content_rating"], "runtime_minutes": show["runtime_minutes"],
|
||
"tagline": show["tagline"], "rating": show["rating"],
|
||
"first_air_date": show["first_air_date"], "last_air_date": show["last_air_date"],
|
||
"imdb_rating": show["imdb_rating"], "rt_rating": show["rt_rating"],
|
||
"metacritic": show["metacritic"],
|
||
"genres": genres, "cast": credits["cast"], "crew": credits["crew"],
|
||
"tmdb_id": show["tmdb_id"], "tvdb_id": show["tvdb_id"], "imdb_id": show["imdb_id"],
|
||
"has_poster": bool(show["poster_url"]), "has_backdrop": bool(show["backdrop_url"]),
|
||
"logo": show["logo_url"],
|
||
"subtitle_langs": _subtitle_langs_list(show["subtitle_langs"]),
|
||
"episodes_synced": bool(show["episodes_synced"]),
|
||
"monitored": bool(show["monitored"]),
|
||
"season_count": len(out_seasons),
|
||
"episode_total": total, "episode_owned": owned_total,
|
||
"seasons": out_seasons,
|
||
}
|
||
|
||
def set_monitored(self, kind: str, item_id: int, monitored: bool) -> bool:
|
||
"""Toggle the 'follow/watchlist' flag on a movie or show. Returns True if a
|
||
row was updated."""
|
||
table = {"movie": "movies", "show": "shows"}.get(kind)
|
||
if not table:
|
||
return False
|
||
conn = self._get_connection()
|
||
try:
|
||
cur = conn.execute(f"UPDATE {table} SET monitored=? WHERE id=?",
|
||
(1 if monitored else 0, item_id))
|
||
conn.commit()
|
||
return cur.rowcount > 0
|
||
finally:
|
||
conn.close()
|
||
|
||
# ── User watchlist (curated follow-list: shows + people) ──────────────────
|
||
# Mirrors the music watchlist_artists model: an explicit follow-list that may
|
||
# include shows/people not in the library yet. Keyed on (kind, tmdb_id). The
|
||
# monitoring/discovery engine is a later phase — these just manage membership.
|
||
# An actively-airing library show (status present and not finished) is on the
|
||
# watchlist BY DEFAULT — owning a still-running show means you want its new
|
||
# episodes. The `state` rows store only explicit user decisions; this default
|
||
# is computed at read time so it always tracks the library + a show's status.
|
||
_ACTIVE_SHOW_SQL = ("status IS NOT NULL AND TRIM(status) <> '' "
|
||
"AND LOWER(status) NOT IN ('ended', 'canceled', 'cancelled', 'completed')")
|
||
|
||
def add_to_watchlist(self, kind: str, tmdb_id: int, title: str,
|
||
poster_url: str | None = None, library_id: int | None = None) -> bool:
|
||
"""Explicitly follow a show/person (state='follow'). Idempotent upsert on
|
||
(kind, tmdb_id) — re-adding refreshes title/poster/library_id and clears
|
||
any 'mute' tombstone. Returns True on success."""
|
||
if kind not in ("show", "person") or not tmdb_id or not title:
|
||
return False
|
||
conn = self._get_connection()
|
||
try:
|
||
conn.execute(
|
||
"""INSERT INTO video_watchlist (kind, tmdb_id, title, poster_url, library_id, state)
|
||
VALUES (?, ?, ?, ?, ?, 'follow')
|
||
ON CONFLICT(kind, tmdb_id) DO UPDATE SET
|
||
state='follow', title=excluded.title,
|
||
poster_url=COALESCE(excluded.poster_url, video_watchlist.poster_url),
|
||
library_id=COALESCE(excluded.library_id, video_watchlist.library_id)""",
|
||
(kind, int(tmdb_id), title, poster_url, library_id))
|
||
conn.commit()
|
||
return True
|
||
except Exception:
|
||
logger.exception("add_to_watchlist failed (%s %s)", kind, tmdb_id)
|
||
return False
|
||
finally:
|
||
conn.close()
|
||
|
||
def remove_from_watchlist(self, kind: str, tmdb_id: int) -> bool:
|
||
"""Un-follow. Stored as a 'mute' tombstone (not a delete) so an
|
||
actively-airing library show — watched by default — is not silently
|
||
re-added. Returns True."""
|
||
if kind not in ("show", "person") or not tmdb_id:
|
||
return False
|
||
conn = self._get_connection()
|
||
try:
|
||
conn.execute(
|
||
"""INSERT INTO video_watchlist (kind, tmdb_id, title, state)
|
||
VALUES (?, ?, '', 'mute')
|
||
ON CONFLICT(kind, tmdb_id) DO UPDATE SET state='mute'""",
|
||
(kind, int(tmdb_id)))
|
||
conn.commit()
|
||
return True
|
||
finally:
|
||
conn.close()
|
||
|
||
# owned/total episode counts — joined off s.id in both queries below.
|
||
_EPS_COLS = ("(SELECT COUNT(*) FROM episodes e WHERE e.show_id=s.id) AS episode_count, "
|
||
"(SELECT COUNT(*) FROM episodes e WHERE e.show_id=s.id AND e.has_file=1) AS owned_count")
|
||
|
||
def _effective_shows(self, conn, server_source) -> list[dict]:
|
||
"""Explicit show follows ∪ actively-airing library shows (not muted),
|
||
each carrying status + owned/total episode counts for the card chrome."""
|
||
out, seen = [], set()
|
||
for r in conn.execute(
|
||
"SELECT w.tmdb_id, w.title, w.poster_url, w.library_id, w.date_added, s.status, "
|
||
+ self._EPS_COLS +
|
||
" FROM video_watchlist w LEFT JOIN shows s ON s.id = w.library_id "
|
||
"WHERE w.kind='show' AND w.state='follow' ORDER BY w.date_added DESC, w.id DESC"):
|
||
d = dict(r); d["kind"] = "show"; out.append(d); seen.add(r["tmdb_id"])
|
||
muted = {r["tmdb_id"] for r in conn.execute(
|
||
"SELECT tmdb_id FROM video_watchlist WHERE kind='show' AND state='mute'")}
|
||
sql = ("SELECT s.tmdb_id, s.title, s.id AS library_id, s.status, " + self._EPS_COLS +
|
||
" FROM shows s WHERE s.tmdb_id IS NOT NULL AND " + self._ACTIVE_SHOW_SQL)
|
||
args: list = []
|
||
if server_source:
|
||
sql += " AND s.server_source = ?"; args.append(server_source)
|
||
sql += " ORDER BY COALESCE(s.sort_title, s.title) COLLATE NOCASE"
|
||
for r in conn.execute(sql, args):
|
||
tid = r["tmdb_id"]
|
||
if tid in seen or tid in muted:
|
||
continue
|
||
seen.add(tid)
|
||
out.append({"kind": "show", "tmdb_id": tid, "title": r["title"],
|
||
"poster_url": "/api/video/poster/show/%d" % r["library_id"],
|
||
"library_id": r["library_id"], "status": r["status"],
|
||
"episode_count": r["episode_count"], "owned_count": r["owned_count"],
|
||
"date_added": None, "auto": True})
|
||
return out
|
||
|
||
def list_watchlist(self, kind: str | None = None, server_source=None) -> list[dict]:
|
||
"""Effective watchlist. Shows include the airing-library default; people
|
||
are explicit follows only."""
|
||
conn = self._get_connection()
|
||
try:
|
||
people = []
|
||
if kind in (None, "person"):
|
||
for r in conn.execute(
|
||
"SELECT tmdb_id, title, poster_url, library_id, date_added FROM video_watchlist "
|
||
"WHERE kind='person' AND state='follow' ORDER BY date_added DESC, id DESC"):
|
||
d = dict(r); d["kind"] = "person"; people.append(d)
|
||
shows = self._effective_shows(conn, server_source) if kind in (None, "show") else []
|
||
if kind == "person":
|
||
return people
|
||
if kind == "show":
|
||
return shows
|
||
return shows + people
|
||
finally:
|
||
conn.close()
|
||
|
||
def watchlist_state(self, kind: str, tmdb_ids, server_source=None) -> dict:
|
||
"""{tmdb_id: True} for ids that are watched — explicit follow OR (for
|
||
shows) an actively-airing library show that isn't muted. Hydrates buttons."""
|
||
out: dict = {}
|
||
ids = [int(x) for x in (tmdb_ids or []) if x]
|
||
if kind not in ("show", "person") or not ids:
|
||
return out
|
||
conn = self._get_connection()
|
||
try:
|
||
for i in range(0, len(ids), 400): # stay under SQLite's variable cap
|
||
chunk = ids[i:i + 400]
|
||
ph = ",".join("?" * len(chunk))
|
||
for r in conn.execute(
|
||
f"SELECT tmdb_id FROM video_watchlist WHERE kind=? AND state='follow' "
|
||
f"AND tmdb_id IN ({ph})", [kind] + chunk):
|
||
out[r["tmdb_id"]] = True
|
||
if kind == "show":
|
||
muted = {r["tmdb_id"] for r in conn.execute(
|
||
f"SELECT tmdb_id FROM video_watchlist WHERE kind='show' AND state='mute' "
|
||
f"AND tmdb_id IN ({ph})", chunk)}
|
||
ssql = f"SELECT tmdb_id FROM shows WHERE tmdb_id IN ({ph}) AND " + self._ACTIVE_SHOW_SQL
|
||
sargs = list(chunk)
|
||
if server_source:
|
||
ssql += " AND server_source = ?"; sargs.append(server_source)
|
||
for r in conn.execute(ssql, sargs):
|
||
if r["tmdb_id"] not in muted:
|
||
out[r["tmdb_id"]] = True
|
||
return out
|
||
finally:
|
||
conn.close()
|
||
|
||
def watchlist_counts(self, server_source=None) -> dict:
|
||
"""{'show': n, 'person': n, 'total': n} over the EFFECTIVE watchlist."""
|
||
shows = self.list_watchlist("show", server_source=server_source)
|
||
people = self.list_watchlist("person")
|
||
return {"show": len(shows), "person": len(people), "total": len(shows) + len(people)}
|
||
|
||
def query_watchlist(self, kind: str, *, search=None, sort="default", page=1, limit=60,
|
||
server_source=None) -> dict:
|
||
"""One searched/sorted/paged slice of the effective watchlist for a kind —
|
||
mirrors query_library's {items, pagination} shape so the page can paginate
|
||
like the library. The effective list is bounded (follows + airing library
|
||
shows), so it's computed then filtered/sorted/sliced rather than via SQL."""
|
||
try:
|
||
page = max(1, int(page or 1))
|
||
limit = max(1, min(200, int(limit or 60)))
|
||
except (TypeError, ValueError):
|
||
page, limit = 1, 60
|
||
items = self.list_watchlist(kind, server_source=server_source) if kind in ("show", "person") else []
|
||
s = (search or "").strip().lower()
|
||
if s:
|
||
items = [it for it in items if s in (it.get("title") or "").lower()]
|
||
if sort == "title":
|
||
items.sort(key=lambda it: (it.get("title") or "").lower())
|
||
elif sort == "added": # explicit follows (have a date) newest-first; airing defaults last
|
||
items.sort(key=lambda it: (it.get("date_added") or ""), reverse=True)
|
||
# "default": keep the natural effective order (follows first, then airing A–Z)
|
||
total = len(items)
|
||
total_pages = max(1, (total + limit - 1) // limit)
|
||
page = min(page, total_pages)
|
||
start = (page - 1) * limit
|
||
return {"items": items[start:start + limit], "pagination": {
|
||
"page": page, "total_pages": total_pages, "total_count": total,
|
||
"has_prev": page > 1, "has_next": page < total_pages}}
|
||
|
||
# ── Wishlist (curated 'get this': movies + episodes) ──────────────────────
|
||
# Atomic units are movies and episodes. Adding a whole show/season expands
|
||
# into episode rows (the caller supplies the explicit episodes); show/season
|
||
# are just bulk add/remove operations over those rows.
|
||
def add_movie_to_wishlist(self, tmdb_id, title, *, year=None, poster_url=None,
|
||
library_id=None, server_source=None) -> bool:
|
||
"""Wish for a movie. Idempotent upsert on its tmdb id."""
|
||
if not tmdb_id or not title:
|
||
return False
|
||
conn = self._get_connection()
|
||
try:
|
||
conn.execute(
|
||
"""INSERT INTO video_wishlist (kind, tmdb_id, title, poster_url, year, library_id, server_source)
|
||
VALUES ('movie', ?, ?, ?, ?, ?, ?)
|
||
ON CONFLICT(tmdb_id) WHERE kind='movie' DO UPDATE SET
|
||
title=excluded.title,
|
||
poster_url=COALESCE(excluded.poster_url, video_wishlist.poster_url),
|
||
year=COALESCE(excluded.year, video_wishlist.year),
|
||
library_id=COALESCE(excluded.library_id, video_wishlist.library_id)""",
|
||
(int(tmdb_id), title, poster_url, year, library_id, server_source))
|
||
conn.commit()
|
||
return True
|
||
except Exception:
|
||
logger.exception("add_movie_to_wishlist failed (%s)", tmdb_id)
|
||
return False
|
||
finally:
|
||
conn.close()
|
||
|
||
def add_episodes_to_wishlist(self, show_tmdb_id, show_title, episodes, *,
|
||
poster_url=None, library_id=None, server_source=None) -> int:
|
||
"""Wish for one or more episodes of a show (the show's tmdb id keys them).
|
||
``episodes`` = [{season_number, episode_number, title?, air_date?}, …].
|
||
Idempotent per (show, season, episode). Returns the count written."""
|
||
if not show_tmdb_id or not show_title or not episodes:
|
||
return 0
|
||
conn = self._get_connection()
|
||
n = 0
|
||
try:
|
||
for e in episodes:
|
||
sn, en = e.get("season_number"), e.get("episode_number")
|
||
if sn is None or en is None:
|
||
continue
|
||
conn.execute(
|
||
"""INSERT INTO video_wishlist
|
||
(kind, tmdb_id, title, poster_url, season_number, episode_number,
|
||
episode_title, still_url, episode_overview, season_poster_url,
|
||
air_date, library_id, server_source)
|
||
VALUES ('episode', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
ON CONFLICT(tmdb_id, season_number, episode_number) WHERE kind='episode' DO UPDATE SET
|
||
title=excluded.title,
|
||
poster_url=COALESCE(excluded.poster_url, video_wishlist.poster_url),
|
||
episode_title=COALESCE(excluded.episode_title, video_wishlist.episode_title),
|
||
still_url=COALESCE(excluded.still_url, video_wishlist.still_url),
|
||
episode_overview=COALESCE(excluded.episode_overview, video_wishlist.episode_overview),
|
||
season_poster_url=COALESCE(excluded.season_poster_url, video_wishlist.season_poster_url),
|
||
air_date=COALESCE(excluded.air_date, video_wishlist.air_date),
|
||
library_id=COALESCE(excluded.library_id, video_wishlist.library_id)""",
|
||
(int(show_tmdb_id), show_title, poster_url, int(sn), int(en),
|
||
e.get("title"), e.get("still_url"), e.get("overview"), e.get("season_poster_url"),
|
||
e.get("air_date"), library_id, server_source))
|
||
n += 1
|
||
conn.commit()
|
||
return n
|
||
except Exception:
|
||
logger.exception("add_episodes_to_wishlist failed (%s)", show_tmdb_id)
|
||
conn.rollback()
|
||
return 0
|
||
finally:
|
||
conn.close()
|
||
|
||
def remove_from_wishlist(self, scope, *, tmdb_id, season_number=None, episode_number=None) -> int:
|
||
"""Remove at any granularity: 'movie' | 'show' (all its episodes) |
|
||
'season' | 'episode'. Returns the number of rows removed."""
|
||
if not tmdb_id:
|
||
return 0
|
||
if scope == "movie":
|
||
sql, args = "DELETE FROM video_wishlist WHERE kind='movie' AND tmdb_id=?", (int(tmdb_id),)
|
||
elif scope == "show":
|
||
sql, args = "DELETE FROM video_wishlist WHERE kind='episode' AND tmdb_id=?", (int(tmdb_id),)
|
||
elif scope == "season":
|
||
if season_number is None:
|
||
return 0
|
||
sql = "DELETE FROM video_wishlist WHERE kind='episode' AND tmdb_id=? AND season_number=?"
|
||
args = (int(tmdb_id), int(season_number))
|
||
elif scope == "episode":
|
||
if season_number is None or episode_number is None:
|
||
return 0
|
||
sql = ("DELETE FROM video_wishlist WHERE kind='episode' AND tmdb_id=? "
|
||
"AND season_number=? AND episode_number=?")
|
||
args = (int(tmdb_id), int(season_number), int(episode_number))
|
||
else:
|
||
return 0
|
||
conn = self._get_connection()
|
||
try:
|
||
cur = conn.execute(sql, args)
|
||
conn.commit()
|
||
return cur.rowcount
|
||
finally:
|
||
conn.close()
|
||
|
||
def wishlist_counts(self) -> dict:
|
||
"""{'movie': n, 'show': n, 'episode': n, 'total': movies+episodes}."""
|
||
conn = self._get_connection()
|
||
try:
|
||
movie = conn.execute("SELECT COUNT(*) c FROM video_wishlist WHERE kind='movie'").fetchone()["c"]
|
||
episode = conn.execute("SELECT COUNT(*) c FROM video_wishlist WHERE kind='episode'").fetchone()["c"]
|
||
shows = conn.execute("SELECT COUNT(DISTINCT tmdb_id) c FROM video_wishlist WHERE kind='episode'").fetchone()["c"]
|
||
return {"movie": movie, "show": shows, "episode": episode, "total": movie + episode}
|
||
finally:
|
||
conn.close()
|
||
|
||
def query_wishlist(self, kind: str, *, search=None, sort="added", page=1, limit=60) -> dict:
|
||
"""One paged slice of the wishlist. kind='movie' → movie cards; kind='show'
|
||
→ shows grouped show→season→episode with wanted/done roll-ups. ``sort`` ∈
|
||
added | title | wanted (wanted = most episodes, shows only). {items,
|
||
pagination} like the other paged queries."""
|
||
try:
|
||
page = max(1, int(page or 1))
|
||
limit = max(1, min(200, int(limit or 60)))
|
||
except (TypeError, ValueError):
|
||
page, limit = 1, 60
|
||
s = (search or "").strip()
|
||
conn = self._get_connection()
|
||
try:
|
||
if kind == "movie":
|
||
where, args = ["kind='movie'"], []
|
||
if s:
|
||
where.append("title LIKE ? COLLATE NOCASE"); args.append("%" + s + "%")
|
||
wsql = " WHERE " + " AND ".join(where)
|
||
order = {"title": "title COLLATE NOCASE", "oldest": "date_added ASC, id ASC",
|
||
"added": "date_added DESC, id DESC"}.get(sort, "date_added DESC, id DESC")
|
||
total = conn.execute("SELECT COUNT(*) c FROM video_wishlist" + wsql, args).fetchone()["c"]
|
||
rows = conn.execute(
|
||
"SELECT tmdb_id, title, poster_url, year, status, library_id, date_added "
|
||
"FROM video_wishlist" + wsql + " ORDER BY " + order + " LIMIT ? OFFSET ?",
|
||
args + [limit, (page - 1) * limit]).fetchall()
|
||
items = [{"kind": "movie", "tmdb_id": r["tmdb_id"], "title": r["title"],
|
||
"poster_url": r["poster_url"], "year": r["year"], "status": r["status"],
|
||
"library_id": r["library_id"]} for r in rows]
|
||
else: # shows (grouped from episode rows)
|
||
where, args = ["kind='episode'"], []
|
||
if s:
|
||
where.append("title LIKE ? COLLATE NOCASE"); args.append("%" + s + "%")
|
||
wsql = " WHERE " + " AND ".join(where)
|
||
total = conn.execute(
|
||
"SELECT COUNT(DISTINCT tmdb_id) c FROM video_wishlist" + wsql, args).fetchone()["c"]
|
||
order = {"title": "title COLLATE NOCASE", "wanted": "wanted DESC, last_added DESC",
|
||
"oldest": "last_added ASC", "added": "last_added DESC"}.get(sort, "last_added DESC")
|
||
show_rows = conn.execute(
|
||
"SELECT tmdb_id, MAX(title) AS title, MAX(poster_url) AS poster_url, "
|
||
"MAX(library_id) AS library_id, COUNT(*) AS wanted, "
|
||
"SUM(CASE WHEN status='downloaded' THEN 1 ELSE 0 END) AS done, "
|
||
"MAX(date_added) AS last_added "
|
||
"FROM video_wishlist" + wsql +
|
||
" GROUP BY tmdb_id ORDER BY " + order + " LIMIT ? OFFSET ?",
|
||
args + [limit, (page - 1) * limit]).fetchall()
|
||
items = []
|
||
for sr in show_rows:
|
||
eps = conn.execute(
|
||
"SELECT season_number, episode_number, episode_title, still_url, "
|
||
"episode_overview, season_poster_url, air_date, status "
|
||
"FROM video_wishlist WHERE kind='episode' AND tmdb_id=? "
|
||
"ORDER BY season_number, episode_number", (sr["tmdb_id"],)).fetchall()
|
||
by_season: dict = {}
|
||
season_poster: dict = {}
|
||
for e in eps:
|
||
by_season.setdefault(e["season_number"], []).append({
|
||
"episode_number": e["episode_number"], "title": e["episode_title"],
|
||
"still_url": e["still_url"], "overview": e["episode_overview"],
|
||
"air_date": e["air_date"], "status": e["status"]})
|
||
if e["season_poster_url"] and e["season_number"] not in season_poster:
|
||
season_poster[e["season_number"]] = e["season_poster_url"]
|
||
seasons = [{"season_number": sn, "poster_url": season_poster.get(sn),
|
||
"episodes": by_season[sn]} for sn in sorted(by_season)]
|
||
items.append({"kind": "show", "tmdb_id": sr["tmdb_id"], "title": sr["title"],
|
||
"poster_url": sr["poster_url"], "library_id": sr["library_id"],
|
||
"wanted": sr["wanted"], "done": sr["done"] or 0, "seasons": seasons})
|
||
finally:
|
||
conn.close()
|
||
total_pages = max(1, (total + limit - 1) // limit)
|
||
page = min(page, total_pages)
|
||
return {"items": items, "pagination": {
|
||
"page": page, "total_pages": total_pages, "total_count": total,
|
||
"has_prev": page > 1, "has_next": page < total_pages}}
|
||
|
||
def wishlist_keys_for_shows(self, show_tmdb_ids) -> dict:
|
||
"""{show_tmdb_id: set('S_E')} of episodes already wishlisted — lets the
|
||
calendar's 'add missing' button skip what's already queued."""
|
||
out: dict = {}
|
||
ids = [int(x) for x in (show_tmdb_ids or []) if x]
|
||
if not ids:
|
||
return out
|
||
conn = self._get_connection()
|
||
try:
|
||
for i in range(0, len(ids), 400):
|
||
chunk = ids[i:i + 400]
|
||
ph = ",".join("?" * len(chunk))
|
||
for r in conn.execute(
|
||
f"SELECT tmdb_id, season_number, episode_number FROM video_wishlist "
|
||
f"WHERE kind='episode' AND tmdb_id IN ({ph})", chunk):
|
||
out.setdefault(r["tmdb_id"], set()).add("%s_%s" % (r["season_number"], r["episode_number"]))
|
||
return out
|
||
finally:
|
||
conn.close()
|
||
|
||
def wishlist_art_backfill_targets(self) -> list:
|
||
"""Distinct (show_tmdb_id, season) with episode rows missing a still OR a
|
||
season poster — one tmdb_season fetch per group fills both (cheap backfill
|
||
for rows added before art-capture existed)."""
|
||
conn = self._get_connection()
|
||
try:
|
||
rows = conn.execute(
|
||
"SELECT DISTINCT tmdb_id, season_number FROM video_wishlist "
|
||
"WHERE kind='episode' AND tmdb_id IS NOT NULL AND season_number IS NOT NULL "
|
||
"AND ((still_url IS NULL OR still_url='') OR "
|
||
" (season_poster_url IS NULL OR season_poster_url='') OR "
|
||
" (episode_overview IS NULL OR episode_overview=''))").fetchall()
|
||
return [{"tmdb_id": r["tmdb_id"], "season_number": r["season_number"]} for r in rows]
|
||
finally:
|
||
conn.close()
|
||
|
||
def set_wishlist_still(self, show_tmdb_id, season_number, episode_number, still_url) -> bool:
|
||
"""Fill a single episode's still (only if it doesn't already have one)."""
|
||
if not still_url:
|
||
return False
|
||
conn = self._get_connection()
|
||
try:
|
||
cur = conn.execute(
|
||
"UPDATE video_wishlist SET still_url=? WHERE kind='episode' AND tmdb_id=? "
|
||
"AND season_number=? AND episode_number=? AND (still_url IS NULL OR still_url='')",
|
||
(still_url, int(show_tmdb_id), int(season_number), int(episode_number)))
|
||
conn.commit()
|
||
return cur.rowcount > 0
|
||
finally:
|
||
conn.close()
|
||
|
||
def set_wishlist_episode_overview(self, show_tmdb_id, season_number, episode_number, overview) -> bool:
|
||
"""Fill a single episode's synopsis (only if it doesn't already have one)."""
|
||
if not overview:
|
||
return False
|
||
conn = self._get_connection()
|
||
try:
|
||
cur = conn.execute(
|
||
"UPDATE video_wishlist SET episode_overview=? WHERE kind='episode' AND tmdb_id=? "
|
||
"AND season_number=? AND episode_number=? AND (episode_overview IS NULL OR episode_overview='')",
|
||
(overview, int(show_tmdb_id), int(season_number), int(episode_number)))
|
||
conn.commit()
|
||
return cur.rowcount > 0
|
||
finally:
|
||
conn.close()
|
||
|
||
def set_wishlist_season_poster(self, show_tmdb_id, season_number, poster_url) -> int:
|
||
"""Fill the season poster on every episode row of a season that lacks one."""
|
||
if not poster_url:
|
||
return 0
|
||
conn = self._get_connection()
|
||
try:
|
||
cur = conn.execute(
|
||
"UPDATE video_wishlist SET season_poster_url=? WHERE kind='episode' AND tmdb_id=? "
|
||
"AND season_number=? AND (season_poster_url IS NULL OR season_poster_url='')",
|
||
(poster_url, int(show_tmdb_id), int(season_number)))
|
||
conn.commit()
|
||
return cur.rowcount
|
||
finally:
|
||
conn.close()
|
||
|
||
def wishlist_state(self, *, movie_ids=None, show_tmdb_id=None) -> dict:
|
||
"""Hydration: which of ``movie_ids`` are wishlisted, and which episode
|
||
keys ('S_E') of ``show_tmdb_id`` are. Returns {movies:set, episodes:set}."""
|
||
out = {"movies": set(), "episodes": set()}
|
||
conn = self._get_connection()
|
||
try:
|
||
ids = [int(x) for x in (movie_ids or []) if x]
|
||
for i in range(0, len(ids), 400):
|
||
chunk = ids[i:i + 400]
|
||
ph = ",".join("?" * len(chunk))
|
||
for r in conn.execute(
|
||
f"SELECT tmdb_id FROM video_wishlist WHERE kind='movie' AND tmdb_id IN ({ph})", chunk):
|
||
out["movies"].add(r["tmdb_id"])
|
||
if show_tmdb_id:
|
||
for r in conn.execute(
|
||
"SELECT season_number, episode_number FROM video_wishlist "
|
||
"WHERE kind='episode' AND tmdb_id=?", (int(show_tmdb_id),)):
|
||
out["episodes"].add("%s_%s" % (r["season_number"], r["episode_number"]))
|
||
return out
|
||
finally:
|
||
conn.close()
|
||
|
||
# ── YouTube channels (bridged onto the watchlist/wishlist tables) ─────────
|
||
# A followed CHANNEL is a video_watchlist row (kind='channel', source='youtube',
|
||
# source_id=channel id). Its wished VIDEOS are video_wishlist rows
|
||
# (kind='video', source_id=video id, parent_source_id=channel id). tmdb_id on
|
||
# both carries the channel's surrogate so existing dedup/grouping just works.
|
||
def add_channel_to_watchlist(self, channel: dict) -> bool:
|
||
"""Follow a YouTube channel. ``channel`` = {youtube_id, title, avatar_url?}.
|
||
Idempotent upsert on the channel surrogate. Returns True on success."""
|
||
cid = (channel or {}).get("youtube_id")
|
||
title = (channel or {}).get("title")
|
||
if not cid or not title:
|
||
return False
|
||
conn = self._get_connection()
|
||
try:
|
||
conn.execute(
|
||
"""INSERT INTO video_watchlist (kind, tmdb_id, title, poster_url, source, source_id, state)
|
||
VALUES ('channel', ?, ?, ?, 'youtube', ?, 'follow')
|
||
ON CONFLICT(kind, tmdb_id) DO UPDATE SET
|
||
state='follow', title=excluded.title,
|
||
poster_url=COALESCE(excluded.poster_url, video_watchlist.poster_url),
|
||
source='youtube', source_id=excluded.source_id""",
|
||
(youtube_surrogate_id(cid), title, channel.get("avatar_url"), cid))
|
||
conn.commit()
|
||
return True
|
||
except Exception:
|
||
logger.exception("add_channel_to_watchlist failed (%s)", cid)
|
||
return False
|
||
finally:
|
||
conn.close()
|
||
|
||
def remove_channel_from_watchlist(self, youtube_id: str) -> bool:
|
||
"""Un-follow a channel (hard delete — channels have no airing-default to
|
||
guard against, so no tombstone). Its already-wished videos are left alone."""
|
||
if not youtube_id:
|
||
return False
|
||
conn = self._get_connection()
|
||
try:
|
||
conn.execute("DELETE FROM video_watchlist WHERE kind='channel' AND source_id=?", (youtube_id,))
|
||
conn.commit()
|
||
return True
|
||
finally:
|
||
conn.close()
|
||
|
||
def list_watchlist_channels(self) -> list[dict]:
|
||
"""Followed channels (newest first): ``video_count`` is the REMEMBERED catalog
|
||
size (from the cache, fills in as the channel is enriched/opened), plus how
|
||
many of its videos are wished."""
|
||
conn = self._get_connection()
|
||
try:
|
||
rows = conn.execute(
|
||
"SELECT w.title, w.poster_url, w.source_id, w.date_added, "
|
||
"(SELECT COUNT(*) FROM youtube_channel_videos cv WHERE cv.channel_id = w.source_id) AS video_count, "
|
||
"(SELECT COUNT(*) FROM video_wishlist v WHERE v.kind='video' "
|
||
" AND v.parent_source_id = w.source_id) AS wished_count "
|
||
"FROM video_watchlist w WHERE w.kind='channel' AND w.state='follow' "
|
||
"ORDER BY w.date_added DESC, w.id DESC").fetchall()
|
||
return [{"kind": "channel", "youtube_id": r["source_id"], "title": r["title"],
|
||
"poster_url": r["poster_url"], "video_count": r["video_count"],
|
||
"wished_count": r["wished_count"], "date_added": r["date_added"]} for r in rows]
|
||
finally:
|
||
conn.close()
|
||
|
||
def channel_watch_state(self, youtube_ids) -> dict:
|
||
"""{youtube_id: True} for followed channels — hydrates the Follow button."""
|
||
out: dict = {}
|
||
ids = [str(x) for x in (youtube_ids or []) if x]
|
||
if not ids:
|
||
return out
|
||
conn = self._get_connection()
|
||
try:
|
||
for i in range(0, len(ids), 400):
|
||
chunk = ids[i:i + 400]
|
||
ph = ",".join("?" * len(chunk))
|
||
for r in conn.execute(
|
||
f"SELECT source_id FROM video_watchlist WHERE kind='channel' "
|
||
f"AND state='follow' AND source_id IN ({ph})", chunk):
|
||
out[r["source_id"]] = True
|
||
return out
|
||
finally:
|
||
conn.close()
|
||
|
||
# A followed PLAYLIST mirrors a channel: a video_watchlist row (kind='playlist',
|
||
# source='youtube', source_id=PL id). Same surrogate scheme so dedup just works.
|
||
def add_playlist_to_watchlist(self, playlist: dict) -> bool:
|
||
"""Follow a YouTube playlist. ``playlist`` = {playlist_id, title, thumbnail_url?}."""
|
||
pid = (playlist or {}).get("playlist_id")
|
||
title = (playlist or {}).get("title")
|
||
if not pid or not title:
|
||
return False
|
||
conn = self._get_connection()
|
||
try:
|
||
conn.execute(
|
||
"""INSERT INTO video_watchlist (kind, tmdb_id, title, poster_url, source, source_id, state)
|
||
VALUES ('playlist', ?, ?, ?, 'youtube', ?, 'follow')
|
||
ON CONFLICT(kind, tmdb_id) DO UPDATE SET
|
||
state='follow', title=excluded.title,
|
||
poster_url=COALESCE(excluded.poster_url, video_watchlist.poster_url),
|
||
source='youtube', source_id=excluded.source_id""",
|
||
(youtube_surrogate_id(pid), title, (playlist or {}).get("thumbnail_url"), pid))
|
||
conn.commit()
|
||
return True
|
||
except Exception:
|
||
logger.exception("add_playlist_to_watchlist failed (%s)", pid)
|
||
return False
|
||
finally:
|
||
conn.close()
|
||
|
||
def remove_playlist_from_watchlist(self, playlist_id: str) -> bool:
|
||
if not playlist_id:
|
||
return False
|
||
conn = self._get_connection()
|
||
try:
|
||
conn.execute("DELETE FROM video_watchlist WHERE kind='playlist' AND source_id=?", (playlist_id,))
|
||
conn.commit()
|
||
return True
|
||
finally:
|
||
conn.close()
|
||
|
||
def list_watchlist_playlists(self) -> list[dict]:
|
||
"""Followed playlists (newest first), each with its remembered video count
|
||
(cached when the playlist is followed/opened)."""
|
||
conn = self._get_connection()
|
||
try:
|
||
rows = conn.execute(
|
||
"SELECT w.title, w.poster_url, w.source_id, w.date_added, "
|
||
"(SELECT COUNT(*) FROM youtube_channel_videos cv WHERE cv.channel_id = w.source_id) AS video_count "
|
||
"FROM video_watchlist w WHERE w.kind='playlist' AND w.state='follow' "
|
||
"ORDER BY w.date_added DESC, w.id DESC").fetchall()
|
||
return [{"kind": "playlist", "playlist_id": r["source_id"], "title": r["title"],
|
||
"poster_url": r["poster_url"], "video_count": r["video_count"],
|
||
"date_added": r["date_added"]} for r in rows]
|
||
finally:
|
||
conn.close()
|
||
|
||
def playlist_watch_state(self, playlist_ids) -> dict:
|
||
"""{playlist_id: True} for followed playlists — hydrates the Follow button."""
|
||
out: dict = {}
|
||
ids = [str(x) for x in (playlist_ids or []) if x]
|
||
if not ids:
|
||
return out
|
||
conn = self._get_connection()
|
||
try:
|
||
for i in range(0, len(ids), 400):
|
||
chunk = ids[i:i + 400]
|
||
ph = ",".join("?" * len(chunk))
|
||
for r in conn.execute(
|
||
f"SELECT source_id FROM video_watchlist WHERE kind='playlist' "
|
||
f"AND state='follow' AND source_id IN ({ph})", chunk):
|
||
out[r["source_id"]] = True
|
||
return out
|
||
finally:
|
||
conn.close()
|
||
|
||
def add_videos_to_wishlist(self, channel: dict, videos: list, *, server_source=None) -> int:
|
||
"""Wish for a channel's videos. ``channel`` = {youtube_id, title, avatar_url?};
|
||
``videos`` = [{youtube_id, title, published_at?, thumbnail_url?, description?}, …].
|
||
Idempotent per video id. Returns the count written."""
|
||
cid = (channel or {}).get("youtube_id")
|
||
ctitle = (channel or {}).get("title")
|
||
if not cid or not ctitle or not videos:
|
||
return 0
|
||
avatar = (channel or {}).get("avatar_url")
|
||
surrogate = youtube_surrogate_id(cid)
|
||
conn = self._get_connection()
|
||
n = 0
|
||
try:
|
||
for v in videos:
|
||
vid = v.get("youtube_id")
|
||
if not vid:
|
||
continue
|
||
conn.execute(
|
||
"""INSERT INTO video_wishlist
|
||
(kind, tmdb_id, title, poster_url, episode_title, still_url,
|
||
episode_overview, air_date, source, source_id, parent_source_id, server_source)
|
||
VALUES ('video', ?, ?, ?, ?, ?, ?, ?, 'youtube', ?, ?, ?)
|
||
ON CONFLICT(source_id) WHERE kind='video' DO UPDATE SET
|
||
title=excluded.title,
|
||
poster_url=COALESCE(excluded.poster_url, video_wishlist.poster_url),
|
||
episode_title=COALESCE(excluded.episode_title, video_wishlist.episode_title),
|
||
still_url=COALESCE(excluded.still_url, video_wishlist.still_url),
|
||
episode_overview=COALESCE(excluded.episode_overview, video_wishlist.episode_overview),
|
||
air_date=COALESCE(excluded.air_date, video_wishlist.air_date)""",
|
||
(surrogate, ctitle, avatar, v.get("title"), v.get("thumbnail_url"),
|
||
v.get("description"), v.get("published_at"), vid, cid, server_source))
|
||
n += 1
|
||
conn.commit()
|
||
return n
|
||
except Exception:
|
||
logger.exception("add_videos_to_wishlist failed (%s)", cid)
|
||
conn.rollback()
|
||
return 0
|
||
finally:
|
||
conn.close()
|
||
|
||
def remove_youtube_from_wishlist(self, scope: str, source_id: str) -> int:
|
||
"""Remove wished videos: scope 'channel' (all of a channel, source_id=channel
|
||
id) or 'video' (one, source_id=video id). Returns rows removed."""
|
||
if not source_id:
|
||
return 0
|
||
if scope == "channel":
|
||
sql = "DELETE FROM video_wishlist WHERE kind='video' AND parent_source_id=?"
|
||
elif scope == "video":
|
||
sql = "DELETE FROM video_wishlist WHERE kind='video' AND source_id=?"
|
||
else:
|
||
return 0
|
||
conn = self._get_connection()
|
||
try:
|
||
cur = conn.execute(sql, (source_id,))
|
||
conn.commit()
|
||
return cur.rowcount
|
||
finally:
|
||
conn.close()
|
||
|
||
def youtube_video_wish_state(self, video_ids) -> set:
|
||
"""Which of ``video_ids`` (youtube ids) are already wished — hydrates the
|
||
per-video buttons on the channel detail page."""
|
||
out: set = set()
|
||
ids = [str(x) for x in (video_ids or []) if x]
|
||
if not ids:
|
||
return out
|
||
conn = self._get_connection()
|
||
try:
|
||
for i in range(0, len(ids), 400):
|
||
chunk = ids[i:i + 400]
|
||
ph = ",".join("?" * len(chunk))
|
||
for r in conn.execute(
|
||
f"SELECT source_id FROM video_wishlist WHERE kind='video' "
|
||
f"AND source_id IN ({ph})", chunk):
|
||
out.add(r["source_id"])
|
||
return out
|
||
finally:
|
||
conn.close()
|
||
|
||
def remove_one_video_from_wishlist(self, video_id) -> int:
|
||
"""Remove a single wished video by its youtube id. (Thin alias kept explicit
|
||
for the detail page's per-video toggle.)"""
|
||
return self.remove_youtube_from_wishlist("video", video_id)
|
||
|
||
def set_wishlist_video_overview(self, video_id, overview) -> bool:
|
||
"""Persist a video's lazily-fetched description onto its wishlist row (only
|
||
if it doesn't already have one) so re-opening is instant — mirrors the
|
||
episode-overview backfill. Returns True if a row was updated."""
|
||
if not overview or not video_id:
|
||
return False
|
||
conn = self._get_connection()
|
||
try:
|
||
cur = conn.execute(
|
||
"UPDATE video_wishlist SET episode_overview=? WHERE kind='video' AND source_id=? "
|
||
"AND (episode_overview IS NULL OR episode_overview='')", (overview, str(video_id)))
|
||
conn.commit()
|
||
return cur.rowcount > 0
|
||
finally:
|
||
conn.close()
|
||
|
||
def set_wishlist_channel_poster(self, channel_id, poster_url) -> int:
|
||
"""Refresh the channel avatar on all of a channel's wished video rows — used
|
||
to backfill avatars that flat listing didn't surface (channel page resolves
|
||
the real avatar). Returns rows updated."""
|
||
if not poster_url or not channel_id:
|
||
return 0
|
||
conn = self._get_connection()
|
||
try:
|
||
cur = conn.execute(
|
||
"UPDATE video_wishlist SET poster_url=? WHERE kind='video' AND parent_source_id=?",
|
||
(poster_url, str(channel_id)))
|
||
conn.commit()
|
||
return cur.rowcount
|
||
finally:
|
||
conn.close()
|
||
|
||
def cache_video_dates(self, pairs) -> int:
|
||
"""Persist learned YouTube upload dates ([{youtube_id, published_at}, …]).
|
||
Idempotent; only stores non-empty dates. Returns rows written."""
|
||
rows = [(p.get("youtube_id"), p.get("published_at")) for p in (pairs or [])
|
||
if p.get("youtube_id") and p.get("published_at")]
|
||
if not rows:
|
||
return 0
|
||
conn = self._get_connection()
|
||
try:
|
||
conn.executemany(
|
||
"INSERT INTO youtube_video_dates (youtube_id, published_at) VALUES (?, ?) "
|
||
"ON CONFLICT(youtube_id) DO UPDATE SET published_at=excluded.published_at", rows)
|
||
conn.commit()
|
||
return len(rows)
|
||
finally:
|
||
conn.close()
|
||
|
||
def get_video_dates(self, video_ids) -> dict:
|
||
"""{youtube_id: published_at} for cached ids — hydrates channel year-seasons."""
|
||
out: dict = {}
|
||
ids = [str(x) for x in (video_ids or []) if x]
|
||
if not ids:
|
||
return out
|
||
conn = self._get_connection()
|
||
try:
|
||
for i in range(0, len(ids), 400):
|
||
chunk = ids[i:i + 400]
|
||
ph = ",".join("?" * len(chunk))
|
||
for r in conn.execute(
|
||
f"SELECT youtube_id, published_at FROM youtube_video_dates WHERE youtube_id IN ({ph})", chunk):
|
||
if r["published_at"]:
|
||
out[r["youtube_id"]] = r["published_at"]
|
||
return out
|
||
finally:
|
||
conn.close()
|
||
|
||
def wishlisted_video_ids_for_channel(self, channel_id) -> list:
|
||
"""The youtube video ids wished under a channel (the per-video date fallback set)."""
|
||
if not channel_id:
|
||
return []
|
||
conn = self._get_connection()
|
||
try:
|
||
return [r["source_id"] for r in conn.execute(
|
||
"SELECT source_id FROM video_wishlist WHERE kind='video' AND parent_source_id=?",
|
||
(str(channel_id),))]
|
||
finally:
|
||
conn.close()
|
||
|
||
def mark_channel_dates_enriched(self, channel_id, date_count=0, method="innertube") -> None:
|
||
"""Record that the background enricher swept this channel (skip re-sweeps).
|
||
``method`` tags which source produced the dates; legacy rows have NULL and
|
||
get re-enriched once so they upgrade to the InnerTube catalog."""
|
||
if not channel_id:
|
||
return
|
||
conn = self._get_connection()
|
||
try:
|
||
conn.execute(
|
||
"INSERT INTO youtube_channel_enrichment (channel_id, enriched_at, date_count, method) "
|
||
"VALUES (?, CURRENT_TIMESTAMP, ?, ?) ON CONFLICT(channel_id) DO UPDATE SET "
|
||
"enriched_at=CURRENT_TIMESTAMP, date_count=excluded.date_count, method=excluded.method",
|
||
(str(channel_id), int(date_count or 0), method or None))
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
|
||
def channel_dates_enriched_recently(self, channel_id, within_hours=24) -> bool:
|
||
"""True if the channel was date-enriched within the window (don't re-sweep).
|
||
Coverage-aware: a run that produced FEW dates (proxies were down) retries
|
||
soon instead of being locked out for the full window — so the catalog
|
||
actually fills in once a source works."""
|
||
if not channel_id:
|
||
return False
|
||
conn = self._get_connection()
|
||
try:
|
||
row = conn.execute(
|
||
"SELECT enriched_at, date_count, method FROM youtube_channel_enrichment WHERE channel_id=?",
|
||
(str(channel_id),)).fetchone()
|
||
if not row or not row["enriched_at"]:
|
||
return False
|
||
# Legacy rows (enriched before InnerTube, method NULL) → always re-enrich
|
||
# once so they upgrade to the full catalog, then settle under the window.
|
||
if not row["method"]:
|
||
return False
|
||
try:
|
||
when = datetime.strptime(row["enriched_at"], "%Y-%m-%d %H:%M:%S")
|
||
except (ValueError, TypeError):
|
||
return False
|
||
now = datetime.now(timezone.utc).replace(tzinfo=None) # naive UTC, matches CURRENT_TIMESTAMP
|
||
# Good coverage → skip for the full window; thin result → retry in 15 min.
|
||
window = within_hours if (row["date_count"] or 0) >= 15 else 0.25
|
||
return (now - when) < timedelta(hours=window)
|
||
finally:
|
||
conn.close()
|
||
|
||
# ── Remembered channel catalog + metadata (cache-first channel pages) ──────
|
||
def cache_channel_videos(self, channel_id, videos) -> int:
|
||
"""Remember a channel's videos (id/title/thumbnail). Upsert — refreshes
|
||
title/thumbnail, never deletes (older pages stay remembered)."""
|
||
cid = str(channel_id or "").strip()
|
||
rows = [(cid, v.get("youtube_id"), v.get("title"), v.get("thumbnail_url"),
|
||
v.get("duration"), v.get("view_count"))
|
||
for v in (videos or []) if isinstance(v, dict) and v.get("youtube_id")]
|
||
if not cid or not rows:
|
||
return 0
|
||
conn = self._get_connection()
|
||
try:
|
||
conn.executemany(
|
||
"INSERT INTO youtube_channel_videos (channel_id, youtube_id, title, thumbnail_url, "
|
||
"duration, view_count) VALUES (?,?,?,?,?,?) ON CONFLICT(channel_id, youtube_id) DO UPDATE SET "
|
||
"title=COALESCE(excluded.title, title), "
|
||
"thumbnail_url=COALESCE(excluded.thumbnail_url, thumbnail_url), "
|
||
"duration=COALESCE(excluded.duration, duration), "
|
||
"view_count=COALESCE(excluded.view_count, view_count), "
|
||
"cached_at=CURRENT_TIMESTAMP", rows)
|
||
conn.commit()
|
||
return len(rows)
|
||
finally:
|
||
conn.close()
|
||
|
||
def get_channel_videos(self, channel_id) -> list:
|
||
"""The remembered video list with dates merged from youtube_video_dates,
|
||
newest first (undated last). [] if nothing is cached for the channel."""
|
||
cid = str(channel_id or "").strip()
|
||
if not cid:
|
||
return []
|
||
conn = self._get_connection()
|
||
try:
|
||
rows = conn.execute(
|
||
"SELECT v.youtube_id, v.title, v.thumbnail_url, v.duration, v.view_count, "
|
||
"d.published_at, s.like_count, s.dislike_count "
|
||
"FROM youtube_channel_videos v "
|
||
"LEFT JOIN youtube_video_dates d ON d.youtube_id = v.youtube_id "
|
||
"LEFT JOIN youtube_video_stats s ON s.youtube_id = v.youtube_id "
|
||
"WHERE v.channel_id=? "
|
||
"ORDER BY (d.published_at IS NULL), d.published_at DESC, v.rowid",
|
||
(cid,)).fetchall()
|
||
return [{"youtube_id": r["youtube_id"], "title": r["title"], "thumbnail_url": r["thumbnail_url"],
|
||
"duration": r["duration"], "view_count": r["view_count"], "published_at": r["published_at"],
|
||
"like_count": r["like_count"], "dislike_count": r["dislike_count"]}
|
||
for r in rows]
|
||
finally:
|
||
conn.close()
|
||
|
||
def cache_channel_meta(self, channel_id, meta) -> None:
|
||
"""Remember a channel's header metadata (avatar/subs/tags/…) for instant re-open."""
|
||
cid = str(channel_id or "").strip()
|
||
if not cid or not isinstance(meta, dict):
|
||
return
|
||
import json
|
||
tags = meta.get("tags")
|
||
conn = self._get_connection()
|
||
try:
|
||
conn.execute(
|
||
"INSERT INTO youtube_channel_meta (channel_id, title, handle, description, "
|
||
"avatar_url, banner_url, subscriber_count, view_count, tags, cached_at) "
|
||
"VALUES (?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP) ON CONFLICT(channel_id) DO UPDATE SET "
|
||
"title=COALESCE(excluded.title, title), handle=COALESCE(excluded.handle, handle), "
|
||
"description=COALESCE(excluded.description, description), "
|
||
"avatar_url=COALESCE(excluded.avatar_url, avatar_url), "
|
||
"banner_url=COALESCE(excluded.banner_url, banner_url), "
|
||
"subscriber_count=COALESCE(excluded.subscriber_count, subscriber_count), "
|
||
"view_count=COALESCE(excluded.view_count, view_count), "
|
||
"tags=COALESCE(excluded.tags, tags), cached_at=CURRENT_TIMESTAMP",
|
||
(cid, meta.get("title"), meta.get("handle"), meta.get("description"),
|
||
meta.get("avatar_url"), meta.get("banner_url"),
|
||
meta.get("subscriber_count"), meta.get("view_count"),
|
||
json.dumps(tags) if tags else None))
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
|
||
def get_channel_meta(self, channel_id):
|
||
"""The remembered channel metadata dict (tags decoded), or None."""
|
||
cid = str(channel_id or "").strip()
|
||
if not cid:
|
||
return None
|
||
import json
|
||
conn = self._get_connection()
|
||
try:
|
||
r = conn.execute("SELECT * FROM youtube_channel_meta WHERE channel_id=?", (cid,)).fetchone()
|
||
if not r:
|
||
return None
|
||
d = dict(r)
|
||
try:
|
||
d["tags"] = json.loads(d["tags"]) if d.get("tags") else []
|
||
except (ValueError, TypeError):
|
||
d["tags"] = []
|
||
return d
|
||
finally:
|
||
conn.close()
|
||
|
||
def youtube_wishlist_counts(self) -> dict:
|
||
"""{'channel': n distinct channels, 'video': n videos} in the wishlist."""
|
||
conn = self._get_connection()
|
||
try:
|
||
video = conn.execute("SELECT COUNT(*) c FROM video_wishlist WHERE kind='video'").fetchone()["c"]
|
||
channel = conn.execute(
|
||
"SELECT COUNT(DISTINCT parent_source_id) c FROM video_wishlist WHERE kind='video'").fetchone()["c"]
|
||
return {"channel": channel, "video": video}
|
||
finally:
|
||
conn.close()
|
||
|
||
def query_youtube_wishlist(self, *, search=None, sort="added", page=1, limit=60) -> dict:
|
||
"""Wished YouTube videos shaped exactly like the TV nebula: channel = show,
|
||
YEAR = season, video = episode. Each channel returns ``seasons`` grouped by
|
||
upload year (newest first), videos as episodes (newest first within a year).
|
||
``sort`` ∈ added | oldest | title | wanted. {items, pagination} like query_wishlist."""
|
||
try:
|
||
page = max(1, int(page or 1))
|
||
limit = max(1, min(200, int(limit or 60)))
|
||
except (TypeError, ValueError):
|
||
page, limit = 1, 60
|
||
s = (search or "").strip()
|
||
conn = self._get_connection()
|
||
try:
|
||
where, args = ["kind='video'"], []
|
||
if s:
|
||
where.append("(title LIKE ? COLLATE NOCASE OR episode_title LIKE ? COLLATE NOCASE)")
|
||
args += ["%" + s + "%", "%" + s + "%"]
|
||
wsql = " WHERE " + " AND ".join(where)
|
||
total = conn.execute(
|
||
"SELECT COUNT(DISTINCT parent_source_id) c FROM video_wishlist" + wsql, args).fetchone()["c"]
|
||
order = {"title": "title COLLATE NOCASE", "wanted": "video_count DESC, last_added DESC",
|
||
"oldest": "last_added ASC", "added": "last_added DESC"}.get(sort, "last_added DESC")
|
||
chan_rows = conn.execute(
|
||
"SELECT parent_source_id, MAX(tmdb_id) AS surrogate, MAX(title) AS title, "
|
||
"MAX(poster_url) AS poster_url, COUNT(*) AS video_count, "
|
||
"SUM(CASE WHEN status='downloaded' THEN 1 ELSE 0 END) AS done, "
|
||
"MAX(date_added) AS last_added "
|
||
"FROM video_wishlist" + wsql +
|
||
" GROUP BY parent_source_id ORDER BY " + order + " LIMIT ? OFFSET ?",
|
||
args + [limit, (page - 1) * limit]).fetchall()
|
||
items = []
|
||
for cr in chan_rows:
|
||
vids = conn.execute(
|
||
"SELECT source_id, episode_title, still_url, episode_overview, air_date, status "
|
||
"FROM video_wishlist WHERE kind='video' AND parent_source_id=? "
|
||
"ORDER BY (air_date IS NULL), air_date DESC, id DESC", (cr["parent_source_id"],)).fetchall()
|
||
# group by upload year → "seasons"; newest video in a year = episode 1
|
||
by_year: dict = {}
|
||
for v in vids:
|
||
ad = v["air_date"]
|
||
yr = int(ad[:4]) if ad and len(ad) >= 4 and ad[:4].isdigit() else 0
|
||
by_year.setdefault(yr, []).append(v)
|
||
seasons = []
|
||
for yr in sorted(by_year, reverse=True): # newest year first
|
||
eps = []
|
||
for i, v in enumerate(by_year[yr]):
|
||
eps.append({"episode_number": i + 1, "title": v["episode_title"],
|
||
"still_url": v["still_url"], "overview": v["episode_overview"],
|
||
"air_date": v["air_date"], "status": v["status"],
|
||
"source_id": v["source_id"]})
|
||
poster = next((e["still_url"] for e in eps if e["still_url"]), cr["poster_url"])
|
||
seasons.append({"season_number": yr, "year": yr, "poster_url": poster, "episodes": eps})
|
||
items.append({
|
||
"kind": "channel", "source": "youtube",
|
||
"tmdb_id": cr["surrogate"], "youtube_id": cr["parent_source_id"],
|
||
"title": cr["title"], "poster_url": cr["poster_url"],
|
||
"wanted": cr["video_count"], "done": cr["done"] or 0, "seasons": seasons})
|
||
finally:
|
||
conn.close()
|
||
total_pages = max(1, (total + limit - 1) // limit)
|
||
page = min(page, total_pages)
|
||
return {"items": items, "pagination": {
|
||
"page": page, "total_pages": total_pages, "total_count": total,
|
||
"has_prev": page > 1, "has_next": page < total_pages}}
|
||
|
||
def movie_detail(self, movie_id: int) -> dict | None:
|
||
"""Full movie detail: the movie + owned/file info. Drives the (isolated)
|
||
video movie-detail page."""
|
||
conn = self._get_connection()
|
||
try:
|
||
m = conn.execute("SELECT * FROM movies WHERE id=?", (movie_id,)).fetchone()
|
||
if not m:
|
||
return None
|
||
genres = self._genres_for(conn, "movie_genres", "movie_id", movie_id)
|
||
credits = self._credits_for(conn, "movie_id", movie_id)
|
||
files = conn.execute(
|
||
"SELECT resolution, quality, video_codec, audio_codec, release_source, size_bytes "
|
||
"FROM media_files WHERE movie_id=? ORDER BY size_bytes DESC",
|
||
(movie_id,)).fetchall()
|
||
finally:
|
||
conn.close()
|
||
return {
|
||
"kind": "movie", "id": m["id"], "title": m["title"], "year": m["year"],
|
||
"overview": m["overview"], "status": m["status"], "studio": m["studio"],
|
||
"release_date": m["release_date"], "runtime_minutes": m["runtime_minutes"],
|
||
"content_rating": m["content_rating"], "tagline": m["tagline"],
|
||
"rating": m["rating"], "rating_critic": m["rating_critic"], "genres": genres,
|
||
"imdb_rating": m["imdb_rating"], "rt_rating": m["rt_rating"], "metacritic": m["metacritic"],
|
||
"cast": credits["cast"], "crew": credits["crew"],
|
||
"tmdb_id": m["tmdb_id"], "imdb_id": m["imdb_id"],
|
||
"has_poster": bool(m["poster_url"]), "has_backdrop": bool(m["backdrop_url"]),
|
||
"logo": m["logo_url"],
|
||
"subtitle_langs": _subtitle_langs_list(m["subtitle_langs"]),
|
||
"owned": bool(m["has_file"]), "monitored": bool(m["monitored"]),
|
||
"file": (dict(files[0]) if files else None), # best version (compat)
|
||
"files": [dict(x) for x in files], # all versions/editions
|
||
}
|
||
|
||
# ── paged/filtered/sorted library query (server-side, like music) ─────────
|
||
def query_library(self, kind: str, *, search=None, letter=None, sort="title",
|
||
status="all", page=1, limit=75, server_source=None) -> dict:
|
||
"""One page of movies/shows with search + A–Z + sort + owned/wanted
|
||
filtering done in SQL. Scoped to ``server_source`` (the active video
|
||
server) so Plex and Jellyfin libraries never commingle — mirrors how the
|
||
music side keeps servers separate. Returns {items, pagination:{...}}."""
|
||
try:
|
||
page = max(1, int(page or 1))
|
||
limit = max(1, min(500, int(limit or 75)))
|
||
except (TypeError, ValueError):
|
||
page, limit = 1, 75
|
||
is_shows = kind == "shows"
|
||
alias = "s" if is_shows else "m"
|
||
tbl = "shows" if is_shows else "movies"
|
||
|
||
where, params = [], []
|
||
if server_source:
|
||
where.append(f"{alias}.server_source = ?")
|
||
params.append(server_source)
|
||
if search:
|
||
where.append(f"{alias}.title LIKE ? COLLATE NOCASE")
|
||
params.append("%" + search + "%")
|
||
if letter and letter != "all":
|
||
col = f"COALESCE({alias}.sort_title, {alias}.title)"
|
||
if letter == "#":
|
||
where.append(f"substr(UPPER({col}), 1, 1) NOT BETWEEN 'A' AND 'Z'")
|
||
else:
|
||
where.append(f"{col} LIKE ? COLLATE NOCASE")
|
||
params.append(letter + "%")
|
||
if not is_shows:
|
||
if status == "owned":
|
||
where.append("m.has_file = 1")
|
||
elif status == "wanted":
|
||
where.append("m.has_file = 0")
|
||
else:
|
||
if status == "owned":
|
||
where.append("EXISTS (SELECT 1 FROM episodes e WHERE e.show_id=s.id AND e.has_file=1)")
|
||
elif status == "wanted":
|
||
where.append("NOT EXISTS (SELECT 1 FROM episodes e WHERE e.show_id=s.id AND e.has_file=1)")
|
||
where_sql = (" WHERE " + " AND ".join(where)) if where else ""
|
||
|
||
title_key = f"COALESCE({alias}.sort_title, {alias}.title) COLLATE NOCASE ASC"
|
||
order_sql = {
|
||
"title": title_key,
|
||
"year": f"{alias}.year DESC, " + title_key,
|
||
"added": f"{alias}.added_at DESC",
|
||
}.get(sort, title_key)
|
||
|
||
if is_shows:
|
||
select = ("SELECT s.id, s.title, s.year, s.tmdb_id, s.status, "
|
||
"(s.poster_url IS NOT NULL AND s.poster_url <> '') AS has_poster, "
|
||
"(SELECT COUNT(*) FROM episodes e WHERE e.show_id=s.id) AS episode_count, "
|
||
"(SELECT COUNT(*) FROM episodes e WHERE e.show_id=s.id AND e.has_file=1) AS owned_count "
|
||
"FROM shows s")
|
||
else:
|
||
select = ("SELECT m.id, m.title, m.year, m.has_file, "
|
||
"(m.poster_url IS NOT NULL AND m.poster_url <> '') AS has_poster, "
|
||
"(SELECT mf.resolution FROM media_files mf WHERE mf.movie_id=m.id LIMIT 1) AS resolution "
|
||
"FROM movies m")
|
||
|
||
conn = self._get_connection()
|
||
try:
|
||
total = conn.execute(f"SELECT COUNT(*) FROM {tbl} {alias}{where_sql}", params).fetchone()[0]
|
||
rows = conn.execute(
|
||
f"{select}{where_sql} ORDER BY {order_sql} LIMIT ? OFFSET ?",
|
||
params + [limit, (page - 1) * limit]).fetchall()
|
||
items = []
|
||
for r in rows:
|
||
d = dict(r)
|
||
d["has_poster"] = bool(d.get("has_poster"))
|
||
if not is_shows:
|
||
d["has_file"] = bool(d.get("has_file"))
|
||
items.append(d)
|
||
total_pages = max(1, (total + limit - 1) // limit)
|
||
return {"items": items, "pagination": {
|
||
"page": page, "total_pages": total_pages, "total_count": total,
|
||
"has_prev": page > 1, "has_next": page < total_pages}}
|
||
finally:
|
||
conn.close()
|
||
|
||
# ── health ───────────────────────────────────────────────────────────────
|
||
def health_check(self) -> bool:
|
||
"""True when the DB opens and passes a quick integrity check."""
|
||
conn = self._get_connection()
|
||
try:
|
||
row = conn.execute("PRAGMA quick_check").fetchone()
|
||
return bool(row) and row[0] == "ok"
|
||
except Exception:
|
||
logger.exception("Video database health check failed")
|
||
return False
|
||
finally:
|
||
conn.close()
|