diff --git a/ytsage/gui/ytsage_gui_feed.py b/ytsage/gui/ytsage_gui_feed.py index 2d24605..8655710 100644 --- a/ytsage/gui/ytsage_gui_feed.py +++ b/ytsage/gui/ytsage_gui_feed.py @@ -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() diff --git a/ytsage/gui/ytsage_gui_main.py b/ytsage/gui/ytsage_gui_main.py index 97857e6..e0e4104 100644 --- a/ytsage/gui/ytsage_gui_main.py +++ b/ytsage/gui/ytsage_gui_main.py @@ -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()): diff --git a/ytsage/languages/en.json b/ytsage/languages/en.json index 8eb10c0..5d06d1a 100644 --- a/ytsage/languages/en.json +++ b/ytsage/languages/en.json @@ -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" } } \ No newline at end of file diff --git a/ytsage/utils/ytsage_library_manager.py b/ytsage/utils/ytsage_library_manager.py new file mode 100644 index 0000000..9129b77 --- /dev/null +++ b/ytsage/utils/ytsage_library_manager.py @@ -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 + ]