Files
SageTube/ytsage/gui/ytsage_gui_feed.py
Homer 996d581a07 Show the account feed after it loads
Refresh fetched the account feed correctly -- the status line said "60 videos
in feed" -- and the page stayed empty with "No subscriptions yet" on it.

The empty-state hint added in 5.5.0 hides the grid so it can sit centred
rather than pinned under a large blank area. Every path that changes the grid
re-runs that check afterwards and shows it again; the account path was the one
that did not, so the grid stayed hidden from startup no matter what was loaded
into it. The local feed re-runs it and was unaffected, which is why this only
showed up for someone signed in.

Two related things while here. The empty-state message was written for local
subscriptions and is wrong advice in account mode, which does not use them --
it now says to press Refresh rather than to go and subscribe to something. And
the Subscriptions panel, which lists local subscriptions, is hidden in account
mode instead of sitting empty beside a full feed and implying something failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 01:38:07 +02:00

491 lines
20 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, QTimer, 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
#: Only refresh a channel automatically if it is at least this stale.
AUTO_REFRESH_DEFAULT_MINUTES = 30
#: Channels touched per automatic refresh. A manual Refresh still does all of
#: them; this only bounds what opening the tab can set off.
AUTO_REFRESH_BATCH = 8
#: Let the tab transition finish before spawning subprocesses.
AUTO_REFRESH_DELAY_MS = 1500
#: The automatic path gives up sooner than a manual refresh: nobody is
#: watching it, and a stuck channel must not block the rest.
AUTO_REFRESH_TIMEOUT = 45
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,
timeout: Optional[int] = None,
) -> None:
super().__init__(parent)
self._subs = subscriptions
self._per_channel = per_channel
self._timeout = timeout
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:
kwargs = {}
if self._timeout is not None:
kwargs["timeout"] = self._timeout
entries = client.fetch_flat_entries(
f"{sub['url'].rstrip('/')}/videos",
start=1,
end=self._per_channel,
use_cache=False,
**kwargs,
)
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):
#: Emitted after subscribe/unsubscribe so other pages can re-read state.
subscriptionsChanged = Signal()
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
#: monotonic timestamp of the last automatic refresh this session
self._last_auto_refresh: float = 0.0
self._done_count = 0
self._failed_count = 0
self._total_count = 0
self._last_error = ""
layout = QVBoxLayout(self)
layout.setContentsMargins(8, 8, 8, 8)
bar = QHBoxLayout()
self.mode_combo = QComboBox()
self.mode_combo.setToolTip(_("feed.mode_tooltip"))
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.setToolTip(_("feed.refresh_tooltip"))
self.refresh_btn.setProperty("sageIcon", "refresh")
self.refresh_btn.clicked.connect(self.refresh)
bar.addWidget(self.refresh_btn)
# Account mode is disabled without cookies. Rather than leaving that
# as a dead end, offer the way out right beside it.
self.signin_btn = QPushButton(_("feed.sign_in"))
self.signin_btn.setToolTip(_("feed.sign_in_tooltip"))
self.signin_btn.setProperty("sageIcon", "user")
self.signin_btn.clicked.connect(self._router.cookieSetupRequested)
bar.addWidget(self.signin_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)
# Kept as an attribute: account mode does not use local subscriptions,
# so the panel is hidden there rather than sitting empty beside a full
# feed and implying something is missing.
self.subs_panel = 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)
grid_host = QWidget()
grid_layout = QVBoxLayout(grid_host)
grid_layout.setContentsMargins(0, 0, 0, 0)
self.grid = VideoCardGrid(router, grid_host)
grid_layout.addWidget(self.grid, stretch=1)
# Feed is the first tab, so an empty feed is the first thing a new
# install shows. Say what to do about it.
self.empty_hint = QLabel("")
self.empty_hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.empty_hint.setWordWrap(True)
self.empty_hint.setStyleSheet("color: #9aa0a6; padding: 24px; font-size: 13px;")
self.empty_hint.hide()
grid_layout.addWidget(self.empty_hint, stretch=1)
# The grid carries its own bare "Nothing here yet"; two empty states
# stacked on one page is one too many.
self.grid.empty_label.hide()
splitter.addWidget(grid_host)
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()
self._update_empty_state()
self.subscriptionsChanged.emit()
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()
# --------------------------------------------------- refresh on opening
def on_tab_activated(self) -> None:
"""
Called by the main window when the Feed tab becomes visible.
The cached grid is already on screen -- it is loaded in the
constructor and kept up to date -- so this only decides whether to go
and fetch. A refresh is one yt-dlp subprocess per subscribed channel,
so it is braked four ways: only channels that are actually stale, at
most one automatic run per interval per session, a batch cap, and a
short delay so it does not race the tab transition or first-run setup.
"""
if self._refresh_worker is not None or self._account_worker is not None:
return
if (self.mode_combo.currentData() or "local") != "local":
return
minutes = ConfigManager.get("feed.auto_refresh_on_open_minutes")
minutes = AUTO_REFRESH_DEFAULT_MINUTES if minutes is None else int(minutes)
if minutes <= 0:
return # opted out
now = time.monotonic()
if self._last_auto_refresh and (now - self._last_auto_refresh) < minutes * 60:
return
cutoff = time.time() - minutes * 60
stale = [s for s in LibraryManager.subscriptions() if not s.get("last_refreshed") or s["last_refreshed"] < cutoff]
if not stale:
return
self._last_auto_refresh = now
batch = stale[:AUTO_REFRESH_BATCH]
if len(stale) > len(batch):
logger.info(f"Feed auto-refresh: {len(batch)} of {len(stale)} stale channels this time.")
QTimer.singleShot(AUTO_REFRESH_DELAY_MS, lambda: self._refresh_local(batch, auto=True))
# --------------------------------------------------------------- local
def _refresh_local(self, subs: Optional[List[Dict[str, Any]]] = None, auto: bool = False) -> None:
if self._refresh_worker is not None:
return
if subs is None:
subs = LibraryManager.subscriptions()
if not subs:
if not auto:
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._failed_count = 0
self._last_error = ""
self._total_count = len(subs)
self._refresh_worker = FeedRefreshWorker(
subs,
per_channel,
parent=self,
timeout=AUTO_REFRESH_TIMEOUT if auto else None,
)
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))
# Merge only this channel's rows. Re-reading the whole feed here
# rebuilt every card after every channel: visible flicker, the scroll
# position lost each time, and thumbnails re-read from disk.
self._merge_channel(channel_id)
def _on_channel_failed(self, channel_id: str, error: str) -> None:
self._done_count += 1
self._failed_count += 1
# Do not write the error into the status line here: the next
# channelDone overwrites it immediately, so failures were invisible.
# It is reported once at the end instead.
self._last_error = error
def _on_refresh_done(self) -> None:
self.refresh_btn.setEnabled(True)
if self._failed_count:
self.status_label.setText(
_("feed.refreshed_with_failures", count=self.grid.card_count(), failed=self._failed_count)
)
logger.warning(f"Feed refresh: {self._failed_count} channel(s) failed; last error: {self._last_error[:200]}")
else:
self.status_label.setText(_("feed.refreshed", count=self.grid.card_count()))
self._update_empty_state()
if self._refresh_worker is not None:
self._refresh_worker.deleteLater()
self._refresh_worker = None
@staticmethod
def _to_entries(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
return [
{
"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
]
def _load_cached_feed(self) -> None:
self.grid.set_entries(self._to_entries(LibraryManager.feed_items()))
self._update_empty_state()
def _merge_channel(self, channel_id: str) -> None:
"""Fold one channel's fresh rows into the grid without rebuilding it."""
try:
items = LibraryManager.feed_items(channel_id=channel_id)
except TypeError:
# Older signature without the filter: fall back to a full reload.
self._load_cached_feed()
return
self.grid.merge_entries(self._to_entries(items))
self._update_empty_state()
def _update_empty_state(self) -> None:
"""
Feed is the first tab now, so an empty one is the first thing a new
user sees. Say what to do about it rather than showing a blank grid.
"""
account_mode = (self.mode_combo.currentData() or "local") == "account"
self.subs_panel.setVisible(not account_mode)
self.grid.empty_label.hide()
if self.grid.card_count() > 0:
self.empty_hint.hide()
self.grid.show()
return
# The message depends on the mode: account mode does not use local
# subscriptions at all, so telling someone to go and subscribe to
# something would be wrong advice.
if account_mode:
message = _("feed.empty_account")
elif not LibraryManager.subscriptions():
message = _("feed.empty_no_subscriptions")
else:
message = _("feed.empty_not_refreshed")
self.empty_hint.setText(message)
# Hide the empty grid so the hint sits in the middle of the page
# rather than pinned under a large blank area.
self.grid.hide()
self.empty_hint.show()
# -------------------------------------------------------------- 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)))
# Without this the grid stays hidden behind the empty-state hint that
# was shown at startup, so a successful refresh reported "60 videos in
# feed" above a "No subscriptions yet" message and no videos.
self._update_empty_state()
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]))
self._update_empty_state()
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)
# Say *why* it is unavailable. It used to be greyed out with no
# explanation and no way to do anything about it.
model_item.setToolTip("" if cookies_on else _("feed.account_requires_cookies"))
if not cookies_on and self.mode_combo.currentData() == "account":
self.mode_combo.setCurrentIndex(self.mode_combo.findData("local"))
# The way out of that dead end: offered only while it is one.
self.signin_btn.setVisible(not cookies_on)
def on_cookies_changed(self) -> None:
"""Cookies were applied elsewhere; account mode may now be usable."""
self._update_mode_availability()
def showEvent(self, event) -> None:
self._update_mode_availability()
super().showEvent(event)
def hideEvent(self, event) -> None:
# Leaving the tab cancels an automatic refresh. cancel() existed and
# was never called, so a background sweep kept running -- and closing
# the window destroyed a live QThread.
self._cancel_refresh()
super().hideEvent(event)
def _cancel_refresh(self) -> None:
worker = self._refresh_worker
if worker is None:
return
worker.cancel()
if not worker.wait(3000):
logger.warning("Feed refresh did not stop in time.")
def shutdown(self) -> None:
"""Called on application close."""
self._cancel_refresh()
def _on_mode_changed(self) -> None:
mode = self.mode_combo.currentData()
ConfigManager.set("feed.mode", mode)
# Switching modes left the previous mode's items on screen, so the
# local feed appeared to be the account feed. Show what the new mode
# actually has.
if mode == "local":
self._load_cached_feed()
else:
self.grid.set_entries([])
self.status_label.setText(_("feed.account_needs_refresh"))
self._update_empty_state()
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()