3afe96a8cd
New ytsage/utils/ytsage_library_manager.py: sagetube_library.db (separate from the upstream download-history DB) with WAL from day one, holding subscriptions, cached feed_items, watch_history with resume positions, and the persisted play_queue. Watch history and queue tables are wired up by the next commit. FeedPage: - Local mode: FeedRefreshWorker refreshes each subscribed channel sequentially (feed.per_channel_items, default 15) and the grid fills incrementally per channel; results are cached so the feed is populated instantly on startup. - Account mode: fetches youtube.com/feed/subscriptions with the user's browser cookies - the real logged-in feed; the option is enabled only while cookies are active and errors surface as a status banner. - Sidebar lists subscriptions (double-click opens the channel in Browse; context menu unsubscribes). Browse's Subscribe button now toggles subscription state through the Feed page. Verified live: subscribe -> refresh -> 15 videos cached in SQLite and rendered as cards. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
268 lines
11 KiB
Python
268 lines
11 KiB
Python
"""
|
|
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) -> List[Dict[str, Any]]:
|
|
with cls._lock:
|
|
rows = cls._conn().execute(
|
|
"SELECT f.*, s.title AS channel FROM feed_items f "
|
|
"LEFT JOIN subscriptions s ON s.channel_id = f.channel_id "
|
|
"ORDER BY COALESCE(f.published_ts, f.fetched_at) DESC LIMIT ?",
|
|
(limit,),
|
|
).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
|
|
]
|