Files
SageTube/ytsage/gui/ytsage_gui_feed.py
T
Jaroslav Beneš 3afe96a8cd 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>
2026-07-25 01:56:12 +02:00

275 lines
11 KiB
Python

"""
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.
"""
import time
from typing import Any, Dict, List, Optional
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)
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()