diff --git a/api/video/__init__.py b/api/video/__init__.py index 6d30bb5a..baba7598 100644 --- a/api/video/__init__.py +++ b/api/video/__init__.py @@ -45,6 +45,7 @@ def create_video_blueprint() -> Blueprint: from .enrichment import register_routes as reg_enrichment from .detail import register_routes as reg_detail from .search import register_routes as reg_search + from .calendar import register_routes as reg_calendar reg_dashboard(bp) reg_scan(bp) reg_library(bp) @@ -53,5 +54,6 @@ def create_video_blueprint() -> Blueprint: reg_enrichment(bp) reg_detail(bp) reg_search(bp) + reg_calendar(bp) return bp diff --git a/api/video/calendar.py b/api/video/calendar.py new file mode 100644 index 00000000..76f7a1f0 --- /dev/null +++ b/api/video/calendar.py @@ -0,0 +1,62 @@ +"""Video Calendar — upcoming TV episodes for OWNED shows. + +GET /api/video/calendar?days=N → episodes airing from today through today+N-1, +grouped client-side into the agenda view. Isolated: reads only video_library.db +via VideoDatabase, writes nothing, never touches the music side. +""" + +from __future__ import annotations + +from datetime import date, timedelta + +from flask import jsonify, request + +from utils.logging_config import get_logger + +logger = get_logger("video.calendar") + + +def register_routes(bp): + @bp.route("/calendar", methods=["GET"]) + def video_calendar(): + from . import get_video_db + try: + days = request.args.get("days", default=7, type=int) or 7 + days = max(1, min(days, 90)) # clamp: today .. +90d + start = date.today() + end = start + timedelta(days=days - 1) + db = get_video_db() + + # One-time backfill: existing shows matched TVDB before air time was + # captured, so re-queue them once (background, only those missing it). + try: + if (db.get_setting("airs_time_backfill") or "") != "1": + n = db.requeue_shows_for_airtime() + db.set_setting("airs_time_backfill", "1") + if n: + logger.info("calendar: queued %d shows for TVDB air-time backfill", n) + except Exception: + logger.exception("airs_time backfill queue failed") + + eps = db.calendar_upcoming(start.isoformat(), end.isoformat()) + + # Per-date counts drive the day-strip dots without a second query. + counts: dict[str, int] = {} + owned = 0 + for e in eps: + counts[e["air_date"]] = counts.get(e["air_date"], 0) + 1 + if e.get("has_file"): + owned += 1 + return jsonify({ + "today": start.isoformat(), + "start": start.isoformat(), + "end": end.isoformat(), + "days": days, + "counts_by_date": counts, + "total": len(eps), + "owned": owned, + "episodes": eps, + }) + except Exception: + logger.exception("video calendar failed") + return jsonify({"error": "calendar failed"}), 500 diff --git a/core/video/enrichment/clients.py b/core/video/enrichment/clients.py index fc565f10..7614a3d9 100644 --- a/core/video/enrichment/clients.py +++ b/core/video/enrichment/clients.py @@ -658,6 +658,9 @@ class TVDBClient: gs = [g.get("name") for g in (sd.get("genres") or []) if g.get("name")] if gs: meta["genres"] = gs + # Show-level air time (network local time, e.g. "21:00") — drives + # the Calendar's per-day time sort. Streaming shows have none. + meta["airs_time"] = (sd.get("airsTime") or "").strip() or None except Exception: logger.exception("TVDB details fetch failed for %s", title or tvdb_id) if tvdb_id is None: diff --git a/database/video_database.py b/database/video_database.py index 7f4dd110..52e5b0d1 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -66,7 +66,7 @@ _ENRICH_META_COLS = { "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", + "tagline", "rating", "first_air_date", "last_air_date", "airs_time", "imdb_id", "tmdb_id", "tvdb_id"}, } @@ -97,6 +97,7 @@ _COLUMN_MIGRATIONS = [ ("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) ] @@ -627,6 +628,20 @@ class VideoDatabase: 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() @@ -1008,6 +1023,31 @@ class VideoDatabase: finally: conn.close() + def calendar_upcoming(self, start_date: str, end_date: str) -> list[dict]: + """Episodes airing in [start_date, end_date] (ISO) for OWNED shows — the + Calendar feed. Owned = the show is on a media server (server_source set), + so we surface upcoming episodes for things the user actually follows. Each + row carries owned/missing (has_file), a still flag, and show network/year + for the card. Ordered by air date then show then episode.""" + 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.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 s.server_source IS NOT NULL " + "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", + (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") diff --git a/database/video_schema.sql b/database/video_schema.sql index b7df72f8..d4603e2b 100644 --- a/database/video_schema.sql +++ b/database/video_schema.sql @@ -120,6 +120,7 @@ CREATE TABLE IF NOT EXISTS shows ( overview TEXT, status TEXT, -- continuing | ended | upcoming network TEXT, + airs_time TEXT, -- TVDB show air time, e.g. "21:00" (network local) runtime_minutes INTEGER, content_rating TEXT, tagline TEXT, diff --git a/webui/index.html b/webui/index.html index 5b0cbaf3..3ce0d9e5 100644 --- a/webui/index.html +++ b/webui/index.html @@ -862,6 +862,32 @@ + + + + + + This Week + Upcoming episodes from your shows + + + + + Loading calendar… + + + + + + 📺 + Nothing scheduled this week + No upcoming episodes for your shows in the next 7 days. + + + + @@ -9470,6 +9496,7 @@ +
Upcoming episodes from your shows