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()):
|
||||
|
||||
Reference in New Issue
Block a user