""" LibraryManager - SageTube's watch-side persistence ================================================== Separate SQLite database (sagetube_library.db) holding local channel subscriptions, the aggregated feed cache, watch history with resume positions, and the persisted play queue. Kept apart from the upstream download-history DB so upstream merges never collide. Same concurrency pattern as HistoryManager: one persistent connection, check_same_thread=False, an RLock around every operation, WAL journaling. """ import sqlite3 import threading import time from typing import Any, Dict, List, Optional from .ytsage_constants import APP_DATA_DIR from .ytsage_logger import logger _SCHEMA_VERSION = 1 class LibraryManager: _lock = threading.RLock() _connection: Optional[sqlite3.Connection] = None _db_file = APP_DATA_DIR / "sagetube_library.db" # ------------------------------------------------------------- plumbing @classmethod def _conn(cls) -> sqlite3.Connection: with cls._lock: if cls._connection is None: cls._db_file.parent.mkdir(parents=True, exist_ok=True) cls._connection = sqlite3.connect(cls._db_file, check_same_thread=False) cls._connection.row_factory = sqlite3.Row cls._connection.execute("PRAGMA journal_mode=WAL") cls._connection.execute("PRAGMA busy_timeout=5000") cls._init_schema() return cls._connection @classmethod def _init_schema(cls) -> None: assert cls._connection is not None cur = cls._connection.cursor() cur.executescript( """ CREATE TABLE IF NOT EXISTS subscriptions ( channel_id TEXT PRIMARY KEY, title TEXT NOT NULL, url TEXT NOT NULL, avatar_url TEXT, added_at INTEGER NOT NULL, last_refreshed INTEGER ); CREATE TABLE IF NOT EXISTS feed_items ( video_id TEXT PRIMARY KEY, channel_id TEXT NOT NULL, title TEXT, url TEXT NOT NULL, duration REAL, thumbnail_url TEXT, published_ts INTEGER, fetched_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_feed_channel ON feed_items(channel_id, fetched_at DESC); CREATE TABLE IF NOT EXISTS watch_history ( video_id TEXT PRIMARY KEY, url TEXT NOT NULL, title TEXT, channel TEXT, channel_id TEXT, duration REAL, position REAL DEFAULT 0, completed INTEGER DEFAULT 0, watched_at INTEGER NOT NULL, thumbnail_url TEXT ); CREATE TABLE IF NOT EXISTS play_queue ( id INTEGER PRIMARY KEY AUTOINCREMENT, video_id TEXT, url TEXT NOT NULL, title TEXT, channel TEXT, duration REAL, thumbnail_url TEXT, sort_order INTEGER NOT NULL, added_at INTEGER NOT NULL ); """ ) cur.execute(f"PRAGMA user_version = {_SCHEMA_VERSION}") cls._connection.commit() # -------------------------------------------------------- subscriptions @classmethod def subscribe(cls, channel_id: str, title: str, url: str, avatar_url: Optional[str] = None) -> None: with cls._lock: cls._conn().execute( "INSERT INTO subscriptions (channel_id, title, url, avatar_url, added_at) VALUES (?,?,?,?,?) " "ON CONFLICT(channel_id) DO UPDATE SET title=excluded.title, url=excluded.url", (channel_id, title, url, avatar_url, int(time.time())), ) cls._conn().commit() logger.info(f"Subscribed to {title} ({channel_id})") @classmethod def unsubscribe(cls, channel_id: str) -> None: with cls._lock: cls._conn().execute("DELETE FROM subscriptions WHERE channel_id = ?", (channel_id,)) cls._conn().execute("DELETE FROM feed_items WHERE channel_id = ?", (channel_id,)) cls._conn().commit() @classmethod def is_subscribed(cls, channel_id: str) -> bool: with cls._lock: row = cls._conn().execute("SELECT 1 FROM subscriptions WHERE channel_id = ?", (channel_id,)).fetchone() return row is not None @classmethod def subscriptions(cls) -> List[Dict[str, Any]]: with cls._lock: rows = cls._conn().execute("SELECT * FROM subscriptions ORDER BY title COLLATE NOCASE").fetchall() return [dict(r) for r in rows] @classmethod def mark_refreshed(cls, channel_id: str) -> None: with cls._lock: cls._conn().execute( "UPDATE subscriptions SET last_refreshed = ? WHERE channel_id = ?", (int(time.time()), channel_id) ) cls._conn().commit() # ---------------------------------------------------------------- feed @classmethod def upsert_feed_items(cls, channel_id: str, entries: List[Dict[str, Any]]) -> None: now = int(time.time()) with cls._lock: conn = cls._conn() for i, e in enumerate(entries): video_id = e.get("id") url = e.get("url") or (f"https://www.youtube.com/watch?v={video_id}" if video_id else None) if not video_id or not url: continue # fetched_at encodes per-channel recency order (newest first) conn.execute( "INSERT INTO feed_items (video_id, channel_id, title, url, duration, thumbnail_url, published_ts, fetched_at) " "VALUES (?,?,?,?,?,?,?,?) " "ON CONFLICT(video_id) DO UPDATE SET title=excluded.title, duration=excluded.duration", ( video_id, channel_id, e.get("title"), url, e.get("duration"), e.get("thumbnail") or (e.get("thumbnails") or [{}])[-1].get("url"), e.get("timestamp") or e.get("release_timestamp"), now - i, ), ) conn.commit() @classmethod def feed_items(cls, limit: int = 120, channel_id: Optional[str] = None) -> List[Dict[str, Any]]: """ Feed rows, newest first. `channel_id` narrows to one channel, which is what lets the Feed merge a finished channel's videos into the grid instead of rebuilding every card. """ query = ( "SELECT f.*, s.title AS channel FROM feed_items f " "LEFT JOIN subscriptions s ON s.channel_id = f.channel_id " ) params: List[Any] = [] if channel_id is not None: query += "WHERE f.channel_id = ? " params.append(str(channel_id)) # published_ts first and only then fetched_at: mixing the two in a # single COALESCE sorted real publish times against wall-clock fetch # times, so whichever channel refreshed last floated to the top. query += "ORDER BY f.published_ts IS NULL, f.published_ts DESC, f.fetched_at DESC LIMIT ?" params.append(limit) with cls._lock: rows = cls._conn().execute(query, tuple(params)).fetchall() return [dict(r) for r in rows] # ------------------------------------------------------- watch history @classmethod def upsert_watch(cls, entry: Dict[str, Any]) -> None: video_id = entry.get("id") or entry.get("url") url = entry.get("url") or entry.get("webpage_url") if not video_id or not url: return with cls._lock: cls._conn().execute( "INSERT INTO watch_history (video_id, url, title, channel, channel_id, duration, watched_at, thumbnail_url) " "VALUES (?,?,?,?,?,?,?,?) " "ON CONFLICT(video_id) DO UPDATE SET watched_at=excluded.watched_at, title=excluded.title, url=excluded.url", ( str(video_id), url, entry.get("title"), entry.get("channel") or entry.get("uploader"), entry.get("channel_id"), entry.get("duration"), int(time.time()), entry.get("thumbnail"), ), ) cls._conn().commit() @classmethod def update_position(cls, video_id: str, position: float, duration: Optional[float] = None) -> None: with cls._lock: completed = 0 if duration and duration > 0 and position >= duration * 0.95: completed = 1 cls._conn().execute( "UPDATE watch_history SET position = ?, completed = MAX(completed, ?), " "duration = COALESCE(?, duration) WHERE video_id = ?", (position, completed, duration, str(video_id)), ) cls._conn().commit() @classmethod def get_resume_position(cls, video_id: str) -> float: with cls._lock: row = cls._conn().execute( "SELECT position, duration, completed FROM watch_history WHERE video_id = ?", (str(video_id),) ).fetchone() if row is None or row["completed"]: return 0.0 position = row["position"] or 0.0 duration = row["duration"] or 0.0 if position < 30 or (duration and position >= duration * 0.95): return 0.0 return float(position) @classmethod def watch_history(cls, limit: int = 200) -> List[Dict[str, Any]]: with cls._lock: rows = cls._conn().execute( "SELECT * FROM watch_history ORDER BY watched_at DESC LIMIT ?", (limit,) ).fetchall() return [dict(r) for r in rows] # ----------------------------------------------------------- play queue @classmethod def save_queue(cls, entries: List[Dict[str, Any]]) -> None: now = int(time.time()) with cls._lock: conn = cls._conn() conn.execute("DELETE FROM play_queue") for i, e in enumerate(entries or []): url = e.get("url") or e.get("webpage_url") if not url: continue conn.execute( "INSERT INTO play_queue (video_id, url, title, channel, duration, thumbnail_url, sort_order, added_at) " "VALUES (?,?,?,?,?,?,?,?)", (e.get("id"), url, e.get("title"), e.get("channel") or e.get("uploader"), e.get("duration"), e.get("thumbnail"), i, now), ) conn.commit() @classmethod def load_queue(cls) -> List[Dict[str, Any]]: with cls._lock: rows = cls._conn().execute("SELECT * FROM play_queue ORDER BY sort_order").fetchall() return [ {"id": r["video_id"], "url": r["url"], "title": r["title"], "channel": r["channel"], "duration": r["duration"], "thumbnail": r["thumbnail_url"]} for r in rows ]