79a6f26c2f
The tab order was Watch, Search, Feed, Browse, Downloads, and the app opened on Watch -- which is where the other tabs send you, not somewhere you start. It is now Feed, Search, Browse, Downloads, Watch, with icons. Routing is unaffected: _tab_index_of resolves by widget identity, not position. Refresh-on-open needed care rather than a call in showEvent. A local refresh is one yt-dlp subprocess per subscribed channel, serially, so it is braked four ways: only channels not seen for feed.auto_refresh_on_open_minutes (30, 0 to disable), at most eight per visit, once per interval per session, and after a delay so it does not race the tab transition or first-run setup. The cached feed is already on screen throughout. That is a new config key rather than the dead auto_refresh_minutes, because existing configs store that as 0 and would have read as opting out of a feature that did not exist yet. SmoothTabWidget gained the currentChanged signal, icons and a corner slot it never had. Its set_current_index needed a re-entrancy flag, not just an index check: setting the tab bar's index emits its currentChanged straight back into the same method, and at that point the stack has not moved, so every switch fired the activation hook twice. The grid was cleared and rebuilt after every channel finished -- flicker, lost scroll position and every thumbnail re-read from disk each time. merge_entries keeps existing cards. While there, _relayout was re-adding cards the layout already owned, so layout items accumulated on every Load more, and the resize check compared against columnCount(), which never shrinks, so it relaid out on every resize event. Smaller things this exposed: feed errors were written into the label the next success overwrote, so failures were invisible; switching to account mode left the local videos on screen; cancel() had no callers, so a refresh outlived the tab and the window; Browse's Subscribe never said Unsubscribe though it toggles; "Play all" queued one page while claiming otherwise; and the feed sorted publish times against wall-clock fetch times in one COALESCE, so the last-refreshed channel floated to the top. Feed is the first thing seen now, so an empty one says what to do about it instead of showing a bare grid. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
455 lines
18 KiB
Python
455 lines
18 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.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)
|
|
|
|
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.
|
|
"""
|
|
self.grid.empty_label.hide()
|
|
if self.grid.card_count() > 0:
|
|
self.empty_hint.hide()
|
|
self.grid.show()
|
|
return
|
|
self.empty_hint.setText(
|
|
_("feed.empty_no_subscriptions")
|
|
if not LibraryManager.subscriptions()
|
|
else _("feed.empty_not_refreshed")
|
|
)
|
|
# 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)))
|
|
|
|
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 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()
|