3afe96a8cd
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>
275 lines
11 KiB
Python
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()
|