""" 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()