Add local subscriptions and the Feed tab
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>
This commit is contained in:
@@ -1,20 +1,274 @@
|
||||
"""
|
||||
Feed tab - subscriptions feed (placeholder, implemented next)
|
||||
Feed tab - subscriptions and their video feed
|
||||
=============================================
|
||||
|
||||
Two modes (config feed.mode):
|
||||
- local: aggregate the most recent uploads of every locally-subscribed
|
||||
channel (no account needed). Channels refresh sequentially in one worker
|
||||
thread; the grid fills incrementally as each channel lands.
|
||||
- account: fetch youtube.com/feed/subscriptions with the user's cookies -
|
||||
the real logged-in feed. Enabled only while cookies are active.
|
||||
|
||||
Subscriptions are stored in LibraryManager (sagetube_library.db); the
|
||||
Browse page's Subscribe button routes here via the main window.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget
|
||||
from PySide6.QtCore import QThread, Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QMenu,
|
||||
QPushButton,
|
||||
QSplitter,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from .ytsage_gui_cards import VideoCardGrid
|
||||
from ..core.ytsage_client import YtdlpClient, YtdlpWorker
|
||||
from ..utils.ytsage_config_manager import ConfigManager
|
||||
from ..utils.ytsage_library_manager import LibraryManager
|
||||
from ..utils.ytsage_localization import _
|
||||
from ..utils.ytsage_logger import logger
|
||||
|
||||
|
||||
class FeedRefreshWorker(QThread):
|
||||
"""Sequentially refresh each subscription's recent uploads."""
|
||||
|
||||
channelDone = Signal(str) # channel_id
|
||||
channelFailed = Signal(str, str) # channel_id, error
|
||||
allDone = Signal()
|
||||
|
||||
def __init__(self, subscriptions: List[Dict[str, Any]], per_channel: int, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self._subs = subscriptions
|
||||
self._per_channel = per_channel
|
||||
self._cancelled = False
|
||||
|
||||
def cancel(self) -> None:
|
||||
self._cancelled = True
|
||||
|
||||
def run(self) -> None:
|
||||
client = YtdlpClient()
|
||||
for sub in self._subs:
|
||||
if self._cancelled:
|
||||
break
|
||||
try:
|
||||
entries = client.fetch_flat_entries(
|
||||
f"{sub['url'].rstrip('/')}/videos", start=1, end=self._per_channel, use_cache=False
|
||||
)
|
||||
LibraryManager.upsert_feed_items(sub["channel_id"], entries)
|
||||
LibraryManager.mark_refreshed(sub["channel_id"])
|
||||
self.channelDone.emit(sub["channel_id"])
|
||||
except Exception as e:
|
||||
logger.warning(f"Feed refresh failed for {sub.get('title')}: {e}")
|
||||
self.channelFailed.emit(sub["channel_id"], str(e))
|
||||
self.allDone.emit()
|
||||
|
||||
|
||||
class FeedPage(QWidget):
|
||||
def __init__(self, router, parent: Optional[QWidget] = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._router = router
|
||||
self._refresh_worker: Optional[FeedRefreshWorker] = None
|
||||
self._account_worker: Optional[YtdlpWorker] = None
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
label = QLabel(_("feed.coming_soon"))
|
||||
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(label)
|
||||
layout.setContentsMargins(8, 8, 8, 8)
|
||||
|
||||
bar = QHBoxLayout()
|
||||
self.mode_combo = QComboBox()
|
||||
self.mode_combo.addItem(_("feed.mode_local"), "local")
|
||||
self.mode_combo.addItem(_("feed.mode_account"), "account")
|
||||
self.mode_combo.setCurrentIndex(0 if (ConfigManager.get("feed.mode") or "local") == "local" else 1)
|
||||
self.mode_combo.currentIndexChanged.connect(self._on_mode_changed)
|
||||
bar.addWidget(self.mode_combo)
|
||||
|
||||
self.refresh_btn = QPushButton(_("feed.refresh"))
|
||||
self.refresh_btn.clicked.connect(self.refresh)
|
||||
bar.addWidget(self.refresh_btn)
|
||||
|
||||
self.status_label = QLabel("")
|
||||
self.status_label.setStyleSheet("color: #9aa0a6;")
|
||||
bar.addWidget(self.status_label, stretch=1)
|
||||
layout.addLayout(bar)
|
||||
|
||||
splitter = QSplitter(Qt.Orientation.Horizontal, self)
|
||||
layout.addWidget(splitter, stretch=1)
|
||||
|
||||
side = QWidget()
|
||||
side_layout = QVBoxLayout(side)
|
||||
side_layout.setContentsMargins(0, 0, 4, 0)
|
||||
subs_label = QLabel(_("feed.subscriptions"))
|
||||
subs_label.setStyleSheet("font-weight: bold;")
|
||||
side_layout.addWidget(subs_label)
|
||||
self.subs_list = QListWidget()
|
||||
self.subs_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self.subs_list.customContextMenuRequested.connect(self._on_subs_context_menu)
|
||||
self.subs_list.itemDoubleClicked.connect(self._on_sub_activated)
|
||||
side_layout.addWidget(self.subs_list)
|
||||
splitter.addWidget(side)
|
||||
|
||||
self.grid = VideoCardGrid(router, self)
|
||||
splitter.addWidget(self.grid)
|
||||
splitter.setStretchFactor(0, 1)
|
||||
splitter.setStretchFactor(1, 4)
|
||||
splitter.setSizes([220, 900])
|
||||
|
||||
self.reload_subscriptions()
|
||||
self._load_cached_feed()
|
||||
self._update_mode_availability()
|
||||
|
||||
# ------------------------------------------------------------ public API
|
||||
|
||||
def subscribe_channel(self, meta: Dict[str, Any]) -> None:
|
||||
"""Wired to BrowsePage.subscribeRequested via the main window."""
|
||||
channel_id = meta.get("channel_id") or meta.get("url")
|
||||
if not channel_id or not meta.get("url"):
|
||||
logger.warning(f"Cannot subscribe, missing channel info: {meta}")
|
||||
return
|
||||
if LibraryManager.is_subscribed(str(channel_id)):
|
||||
LibraryManager.unsubscribe(str(channel_id))
|
||||
self.status_label.setText(_("feed.unsubscribed", title=meta.get("title") or channel_id))
|
||||
else:
|
||||
LibraryManager.subscribe(str(channel_id), meta.get("title") or str(channel_id), meta["url"], meta.get("avatar_url"))
|
||||
self.status_label.setText(_("feed.subscribed", title=meta.get("title") or channel_id))
|
||||
self.reload_subscriptions()
|
||||
|
||||
def reload_subscriptions(self) -> None:
|
||||
self.subs_list.clear()
|
||||
for sub in LibraryManager.subscriptions():
|
||||
item = QListWidgetItem(sub["title"])
|
||||
item.setData(Qt.ItemDataRole.UserRole, sub)
|
||||
self.subs_list.addItem(item)
|
||||
|
||||
def refresh(self) -> None:
|
||||
mode = self.mode_combo.currentData()
|
||||
ConfigManager.set("feed.mode", mode)
|
||||
if mode == "account":
|
||||
self._refresh_account()
|
||||
else:
|
||||
self._refresh_local()
|
||||
|
||||
# --------------------------------------------------------------- local
|
||||
|
||||
def _refresh_local(self) -> None:
|
||||
if self._refresh_worker is not None:
|
||||
return
|
||||
subs = LibraryManager.subscriptions()
|
||||
if not subs:
|
||||
self.status_label.setText(_("feed.no_subscriptions"))
|
||||
return
|
||||
per_channel = int(ConfigManager.get("feed.per_channel_items") or 15)
|
||||
self.refresh_btn.setEnabled(False)
|
||||
self.status_label.setText(_("feed.refreshing", done=0, total=len(subs)))
|
||||
self._done_count = 0
|
||||
self._total_count = len(subs)
|
||||
self._refresh_worker = FeedRefreshWorker(subs, per_channel, parent=self)
|
||||
self._refresh_worker.channelDone.connect(self._on_channel_done)
|
||||
self._refresh_worker.channelFailed.connect(self._on_channel_failed)
|
||||
self._refresh_worker.allDone.connect(self._on_refresh_done)
|
||||
self._refresh_worker.start()
|
||||
|
||||
def _on_channel_done(self, channel_id: str) -> None:
|
||||
self._done_count += 1
|
||||
self.status_label.setText(_("feed.refreshing", done=self._done_count, total=self._total_count))
|
||||
self._load_cached_feed()
|
||||
|
||||
def _on_channel_failed(self, channel_id: str, error: str) -> None:
|
||||
self._done_count += 1
|
||||
self.status_label.setText(_("feed.channel_failed", error=error[:120]))
|
||||
|
||||
def _on_refresh_done(self) -> None:
|
||||
self.refresh_btn.setEnabled(True)
|
||||
self.status_label.setText(_("feed.refreshed", count=self.grid.card_count()))
|
||||
if self._refresh_worker is not None:
|
||||
self._refresh_worker.deleteLater()
|
||||
self._refresh_worker = None
|
||||
|
||||
def _load_cached_feed(self) -> None:
|
||||
items = LibraryManager.feed_items()
|
||||
entries = [
|
||||
{
|
||||
"id": it["video_id"],
|
||||
"url": it["url"],
|
||||
"title": it["title"],
|
||||
"channel": it.get("channel"),
|
||||
"duration": it["duration"],
|
||||
"thumbnail": it["thumbnail_url"],
|
||||
}
|
||||
for it in items
|
||||
]
|
||||
self.grid.set_entries(entries)
|
||||
|
||||
# -------------------------------------------------------------- account
|
||||
|
||||
def _refresh_account(self) -> None:
|
||||
if self._account_worker is not None:
|
||||
return
|
||||
self.refresh_btn.setEnabled(False)
|
||||
self.status_label.setText(_("feed.fetching_account"))
|
||||
self._account_worker = YtdlpWorker(lambda c: c.fetch_account_feed(n=60), parent=self)
|
||||
self._account_worker.result.connect(self._on_account_feed)
|
||||
self._account_worker.error.connect(self._on_account_error)
|
||||
self._account_worker.finished.connect(self._on_account_finished)
|
||||
self._account_worker.start()
|
||||
|
||||
def _on_account_feed(self, entries: List[Dict[str, Any]]) -> None:
|
||||
self.grid.set_entries(entries)
|
||||
self.status_label.setText(_("feed.refreshed", count=len(entries)))
|
||||
|
||||
def _on_account_error(self, message: str) -> None:
|
||||
logger.error(f"Account feed failed: {message}")
|
||||
self.status_label.setText(_("feed.account_failed", error=message[:200]))
|
||||
|
||||
def _on_account_finished(self) -> None:
|
||||
self.refresh_btn.setEnabled(True)
|
||||
if self._account_worker is not None:
|
||||
self._account_worker.deleteLater()
|
||||
self._account_worker = None
|
||||
|
||||
# ------------------------------------------------------------- internal
|
||||
|
||||
def _update_mode_availability(self) -> None:
|
||||
cookies_on = bool(ConfigManager.get("cookie_active"))
|
||||
account_index = self.mode_combo.findData("account")
|
||||
model_item = self.mode_combo.model().item(account_index)
|
||||
if model_item is not None:
|
||||
model_item.setEnabled(cookies_on)
|
||||
if not cookies_on and self.mode_combo.currentData() == "account":
|
||||
self.mode_combo.setCurrentIndex(self.mode_combo.findData("local"))
|
||||
|
||||
def showEvent(self, event) -> None:
|
||||
self._update_mode_availability()
|
||||
super().showEvent(event)
|
||||
|
||||
def _on_mode_changed(self) -> None:
|
||||
ConfigManager.set("feed.mode", self.mode_combo.currentData())
|
||||
|
||||
def _on_sub_activated(self, item: QListWidgetItem) -> None:
|
||||
sub = item.data(Qt.ItemDataRole.UserRole)
|
||||
if sub and sub.get("url"):
|
||||
self._router.openChannel.emit(sub["url"])
|
||||
|
||||
def _on_subs_context_menu(self, pos) -> None:
|
||||
item = self.subs_list.itemAt(pos)
|
||||
if item is None:
|
||||
return
|
||||
sub = item.data(Qt.ItemDataRole.UserRole)
|
||||
menu = QMenu(self)
|
||||
open_action = menu.addAction(_("feed.open_channel"))
|
||||
unsub_action = menu.addAction(_("browse.unsubscribe"))
|
||||
action = menu.exec(self.subs_list.mapToGlobal(pos))
|
||||
if action is open_action and sub.get("url"):
|
||||
self._router.openChannel.emit(sub["url"])
|
||||
elif action is unsub_action:
|
||||
LibraryManager.unsubscribe(sub["channel_id"])
|
||||
self.reload_subscriptions()
|
||||
self._load_cached_feed()
|
||||
|
||||
@@ -659,6 +659,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
|
||||
self.router.downloadVideo.connect(self._route_download_video)
|
||||
self.router.openChannel.connect(self._route_open_channel)
|
||||
self.router.openPlaylist.connect(self._route_open_playlist)
|
||||
self.browse_page.subscribeRequested.connect(self.feed_page.subscribe_channel)
|
||||
|
||||
def _tab_index_of(self, page) -> int:
|
||||
for i in range(self.main_tabs.stack.count()):
|
||||
|
||||
@@ -689,6 +689,18 @@
|
||||
"hint": "Open a channel (youtube.com/@name) or playlist URL, or navigate here from Search."
|
||||
},
|
||||
"feed": {
|
||||
"coming_soon": "Subscriptions feed coming soon"
|
||||
"mode_local": "Local subscriptions",
|
||||
"mode_account": "YouTube account (cookies)",
|
||||
"refresh": "Refresh",
|
||||
"subscriptions": "Subscriptions",
|
||||
"no_subscriptions": "No subscriptions yet - subscribe from a channel page in Browse",
|
||||
"refreshing": "Refreshing... {done}/{total}",
|
||||
"refreshed": "{count} videos in feed",
|
||||
"channel_failed": "A channel failed to refresh: {error}",
|
||||
"fetching_account": "Fetching your subscription feed...",
|
||||
"account_failed": "Account feed failed (are cookies valid?): {error}",
|
||||
"subscribed": "Subscribed to {title}",
|
||||
"unsubscribed": "Unsubscribed from {title}",
|
||||
"open_channel": "Open channel"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
"""
|
||||
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
|
||||
]
|
||||
Reference in New Issue
Block a user