Open on the Feed, and refresh it without hammering yt-dlp

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>
This commit is contained in:
2026-08-09 01:11:40 +02:00
parent 27933dd495
commit 79a6f26c2f
9 changed files with 453 additions and 80 deletions
+56 -34
View File
@@ -39,6 +39,40 @@ records.
application had a tooltip. An application-level polish filter now fills them application had a tooltip. An application-level polish filter now fills them
in as each button is first shown, and swaps placeholder emoji for icons — in as each button is first shown, and swaps placeholder emoji for icons —
which covers dialogs owned by upstream without editing them. which covers dialogs owned by upstream without editing them.
- **Skip this version** in the update dialog. The skipped version is never
offered again; later ones still are.
### Changed
- **The tab order is Feed → Search → Browse → Downloads → Watch**, each with an
icon, and the app opens on the Feed. Watch was first, which is where the
other tabs send you rather than somewhere you start.
- **Opening the Feed refreshes it**, but only channels not seen for 30 minutes
(`feed.auto_refresh_on_open_minutes`, 0 to switch off), at most eight per
visit, once per interval per session, and after a short delay. A refresh is
one yt-dlp subprocess per subscribed channel, so an unthrottled one would be
a lot of them. The cached feed still appears instantly; manual Refresh always
does everything.
- mpv is given an explicit `hwdec` list per platform (`player.hwdec`, default
`auto`), each ending in software decoding, so a machine with broken GPU
interop plays rather than showing a black frame. This does **not** silence
`Cannot load libcuda.so.1` — that comes from the driver stack below mpv and
appears with `hwdec=no` too.
- **The update check now looks at SageTube's own releases.** It queried PyPI's
`ytsage` package for the version and `oop7/YTSage` for the changelog, then
linked to upstream's downloads — a different program's release stream. It now
reads `git.houmeres.sk/Houmeres/SageTube` releases, anonymously, and the
"Download update" button opens this repository's release page.
- The beta channel follows SageTube pre-releases. Upstream's inherited `b` tags
(`v5.3.0b` and earlier) read as pre-releases, so a stable instance cannot be
offered one.
- The check now runs at most once a day rather than on every start.
- The About dialog and the update settings say SageTube rather than YTSage. The
"Based on YTSage by oop7" attribution link stays — it is the MIT credit.
- Configs now carry `config_version`. A file written before 5.4.0 — including one
inherited from an upstream YTSage install — has its stored `check_app_updates`
cleared once, so SageTube's own default applies rather than a setting that
pointed at another project's releases.
### Fixed ### Fixed
@@ -57,6 +91,28 @@ records.
- The window icon shipped only at 48px and was upscaled everywhere; the 256px - The window icon shipped only at 48px and was upscaled everywhere; the 256px
master now ships beside it. Its fallback was the platform's download arrow master now ships beside it. Its fallback was the platform's download arrow
standing in for the application's identity. standing in for the application's identity.
- **The feed grid no longer flickers or loses your scroll position.** It was
cleared and rebuilt from scratch after every channel finished, re-reading
every thumbnail from disk each time; finished channels are now merged in and
existing cards left alone. The docstring's claim that it "fills
incrementally" is finally true.
- Card layout items accumulated: each `Load more` re-added cards the layout
already held, and the resize check compared against `columnCount()`, which
never shrinks — so it relaid out on every single resize event.
- Feed failures were invisible. The error was written into the same label the
next successful channel immediately overwrote; the count is now reported once
when the refresh ends.
- Switching the feed to account mode left the local videos on screen, so one
mode's contents appeared to be the other's.
- Leaving the Feed tab or closing the window cancels a running refresh.
`cancel()` existed and had no callers, so a sweep kept running and the window
could be closed while its thread was live.
- Browse's Subscribe button never said "Unsubscribe", although pressing it
toggles; its only feedback was a status line on a different tab.
- "Play all" quietly queued just the loaded page and claimed to be everything.
- The feed sorted by publish time and fetch time in the same expression, so the
most recently refreshed channel floated to the top regardless of how old its
videos were.
- **The player no longer crashes the app.** Qt destroys and recreates a - **The player no longer crashes the app.** Qt destroys and recreates a
widget's OpenGL context whenever it moves to another top-level window, and widget's OpenGL context whenever it moves to another top-level window, and
calls `initializeGL()` again. libmpv allows one render context per handle, so calls `initializeGL()` again. libmpv allows one render context per handle, so
@@ -83,33 +139,6 @@ records.
reason — every dialog in the app screenshotted the whole window. reason — every dialog in the app screenshotted the whole window.
- The saved volume never reached mpv: the slider set its restored value before - The saved volume never reached mpv: the slider set its restored value before
its change signal was connected, so playback always started at 100. its change signal was connected, so playback always started at 100.
### Changed
- mpv is given an explicit `hwdec` list per platform (`player.hwdec`, default
`auto`), each ending in software decoding, so a machine with broken GPU
interop plays rather than showing a black frame. This does **not** silence
`Cannot load libcuda.so.1` — that comes from the driver stack below mpv and
appears with `hwdec=no` too.
- **The update check now looks at SageTube's own releases.** It queried PyPI's
`ytsage` package for the version and `oop7/YTSage` for the changelog, then
linked to upstream's downloads — a different program's release stream. It now
reads `git.houmeres.sk/Houmeres/SageTube` releases, anonymously, and the
"Download update" button opens this repository's release page.
- The beta channel follows SageTube pre-releases. Upstream's inherited `b` tags
(`v5.3.0b` and earlier) read as pre-releases, so a stable instance cannot be
offered one.
- The check now runs at most once a day rather than on every start.
- The About dialog and the update settings say SageTube rather than YTSage. The
"Based on YTSage by oop7" attribution link stays — it is the MIT credit.
### Added
- **Skip this version** in the update dialog. The skipped version is never
offered again; later ones still are.
### Fixed
- **The app no longer offers upstream YTSage's releases as its own updates.** - **The app no longer offers upstream YTSage's releases as its own updates.**
`__version__` and `pyproject.toml` had been left at the scaffolded `0.1.0` `__version__` and `pyproject.toml` had been left at the scaffolded `0.1.0`
while the released tag was `v5.4.0`, so every comparison against upstream's while the released tag was `v5.4.0`, so every comparison against upstream's
@@ -124,13 +153,6 @@ records.
the settings tab read a missing `check_app_updates` as enabled and then the settings tab read a missing `check_app_updates` as enabled and then
persisted that reading unconditionally. persisted that reading unconditionally.
### Changed
- Configs now carry `config_version`. A file written before 5.4.0 — including one
inherited from an upstream YTSage install — has its stored `check_app_updates`
cleared once, so SageTube's own default applies rather than a setting that
pointed at another project's releases.
## 5.4.0 — 2026-08-08 ## 5.4.0 — 2026-08-08
### Changed ### Changed
+22 -1
View File
@@ -27,6 +27,7 @@ from PySide6.QtWidgets import (
from .ytsage_gui_cards import VideoCardGrid from .ytsage_gui_cards import VideoCardGrid
from ..core.ytsage_client import YtdlpWorker from ..core.ytsage_client import YtdlpWorker
from ..utils.ytsage_library_manager import LibraryManager
from ..utils.ytsage_localization import _ from ..utils.ytsage_localization import _
from ..utils.ytsage_logger import logger from ..utils.ytsage_logger import logger
@@ -239,6 +240,9 @@ class BrowsePage(QWidget):
} }
if self._channel_meta.get("title"): if self._channel_meta.get("title"):
self.title_label.setText(self._channel_meta["title"]) self.title_label.setText(self._channel_meta["title"])
# Now that the real channel id is known, the button can say whether
# this channel is already followed.
self.refresh_subscribe_state()
def _on_meta_finished(self) -> None: def _on_meta_finished(self) -> None:
if self._meta_worker is not None: if self._meta_worker is not None:
@@ -250,13 +254,30 @@ class BrowsePage(QWidget):
if not meta.get("title"): if not meta.get("title"):
meta["title"] = self.title_label.text() meta["title"] = self.title_label.text()
self.subscribeRequested.emit(meta) self.subscribeRequested.emit(meta)
# The handler toggles, so reflect the new state here. Previously the
# button always read "Subscribe" whichever way it had just gone, and
# the only feedback was a status line on a different tab.
self.refresh_subscribe_state()
def refresh_subscribe_state(self) -> None:
"""Make the button say what pressing it will now do."""
channel_id = self._channel_meta.get("channel_id") or self._channel_meta.get("url") or self._current_url
subscribed = bool(channel_id) and LibraryManager.is_subscribed(str(channel_id))
self.subscribe_btn.setText(_("browse.unsubscribe") if subscribed else _("browse.subscribe"))
self.subscribe_btn.setToolTip(
_("browse.unsubscribe_tooltip") if subscribed else _("browse.subscribe_tooltip")
)
def _on_play_all(self) -> None: def _on_play_all(self) -> None:
for entry in [c.entry for c in self.playlist_section.grid._cards]: cards = list(self.playlist_section.grid._cards)
for entry in [c.entry for c in cards]:
e = dict(entry) e = dict(entry)
if not e.get("url") and e.get("id"): if not e.get("url") and e.get("id"):
e["url"] = f"https://www.youtube.com/watch?v={e['id']}" e["url"] = f"https://www.youtube.com/watch?v={e['id']}"
self._router.queueVideo.emit(e) self._router.queueVideo.emit(e)
# "Play all" queues what has been loaded, which is one page unless the
# user pressed Load more. Say so rather than implying the whole list.
self.playlist_section.status_label.setText(_("browse.queued_loaded", count=len(cards)))
def _on_download_playlist(self) -> None: def _on_download_playlist(self) -> None:
if self._current_url: if self._current_url:
+54 -1
View File
@@ -207,6 +207,9 @@ class VideoCardGrid(QScrollArea):
super().__init__(parent) super().__init__(parent)
self._router = router self._router = router
self._cards: List[VideoCard] = [] self._cards: List[VideoCard] = []
#: video id -> card, so merge_entries can tell new from existing
self._by_key: Dict[str, "VideoCard"] = {}
self._columns_in_use = 0
self.setWidgetResizable(True) self.setWidgetResizable(True)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
@@ -246,15 +249,56 @@ class VideoCardGrid(QScrollArea):
for entry in entries: for entry in entries:
card = VideoCard(entry, self._router, self._grid_widget) card = VideoCard(entry, self._router, self._grid_widget)
self._cards.append(card) self._cards.append(card)
self._by_key[self._key(entry)] = card
self._relayout() self._relayout()
self.load_more_btn.setVisible(show_load_more) self.load_more_btn.setVisible(show_load_more)
self.empty_label.setVisible(not self._cards) self.empty_label.setVisible(not self._cards)
@staticmethod
def _key(entry: Dict[str, Any]) -> str:
return str(entry.get("id") or entry.get("url") or id(entry))
def merge_entries(self, entries: List[Dict[str, Any]], prune: bool = False) -> None:
"""
Fold entries in, keeping cards that are already here.
set_entries() destroys and rebuilds every card, which during a feed
refresh happened once per channel: the grid flickered, the scroll
position was lost each time, and every thumbnail was re-read from
disk. Existing cards are left alone here, so only genuinely new
videos cost anything.
"""
incoming = {self._key(e): e for e in entries}
scroll = self.verticalScrollBar().value()
self.setUpdatesEnabled(False)
try:
for key, entry in incoming.items():
if key in self._by_key:
continue
card = VideoCard(entry, self._router, self._grid_widget)
self._cards.append(card)
self._by_key[key] = card
if prune:
for key in [k for k in self._by_key if k not in incoming]:
card = self._by_key.pop(key)
if card in self._cards:
self._cards.remove(card)
self._grid.removeWidget(card)
card.deleteLater()
self._relayout()
self.empty_label.setVisible(not self._cards)
finally:
self.setUpdatesEnabled(True)
self.verticalScrollBar().setValue(scroll)
def clear(self) -> None: def clear(self) -> None:
for card in self._cards: for card in self._cards:
self._grid.removeWidget(card) self._grid.removeWidget(card)
card.deleteLater() card.deleteLater()
self._cards = [] self._cards = []
self._by_key.clear()
self.empty_label.setVisible(True) self.empty_label.setVisible(True)
self.load_more_btn.setVisible(False) self.load_more_btn.setVisible(False)
@@ -269,10 +313,19 @@ class VideoCardGrid(QScrollArea):
def _relayout(self) -> None: def _relayout(self) -> None:
cols = self._columns() cols = self._columns()
# Drain first. addWidget on a card the layout already owns adds a
# second item for it, so the layout grew an extra entry per card on
# every append. takeAt detaches without deleting the widget.
while self._grid.count():
self._grid.takeAt(0)
for i, card in enumerate(self._cards): for i, card in enumerate(self._cards):
self._grid.addWidget(card, i // cols, i % cols) self._grid.addWidget(card, i // cols, i % cols)
self._columns_in_use = cols
def resizeEvent(self, event) -> None: def resizeEvent(self, event) -> None:
super().resizeEvent(event) super().resizeEvent(event)
if self._cards and self._columns() != self._grid.columnCount(): # Compared against what was actually laid out, not columnCount():
# QGridLayout never shrinks its column count, so that comparison
# stayed true forever and relaid out on every resize event.
if self._cards and self._columns() != self._columns_in_use:
self._relayout() self._relayout()
+195 -17
View File
@@ -16,7 +16,7 @@ Browse page's Subscribe button routes here via the main window.
import time import time
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from PySide6.QtCore import QThread, Qt, Signal from PySide6.QtCore import QThread, QTimer, Qt, Signal
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QComboBox, QComboBox,
QHBoxLayout, QHBoxLayout,
@@ -38,6 +38,18 @@ from ..utils.ytsage_localization import _
from ..utils.ytsage_logger import logger 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): class FeedRefreshWorker(QThread):
"""Sequentially refresh each subscription's recent uploads.""" """Sequentially refresh each subscription's recent uploads."""
@@ -45,10 +57,17 @@ class FeedRefreshWorker(QThread):
channelFailed = Signal(str, str) # channel_id, error channelFailed = Signal(str, str) # channel_id, error
allDone = Signal() allDone = Signal()
def __init__(self, subscriptions: List[Dict[str, Any]], per_channel: int, parent=None) -> None: def __init__(
self,
subscriptions: List[Dict[str, Any]],
per_channel: int,
parent=None,
timeout: Optional[int] = None,
) -> None:
super().__init__(parent) super().__init__(parent)
self._subs = subscriptions self._subs = subscriptions
self._per_channel = per_channel self._per_channel = per_channel
self._timeout = timeout
self._cancelled = False self._cancelled = False
def cancel(self) -> None: def cancel(self) -> None:
@@ -60,8 +79,15 @@ class FeedRefreshWorker(QThread):
if self._cancelled: if self._cancelled:
break break
try: try:
kwargs = {}
if self._timeout is not None:
kwargs["timeout"] = self._timeout
entries = client.fetch_flat_entries( entries = client.fetch_flat_entries(
f"{sub['url'].rstrip('/')}/videos", start=1, end=self._per_channel, use_cache=False f"{sub['url'].rstrip('/')}/videos",
start=1,
end=self._per_channel,
use_cache=False,
**kwargs,
) )
LibraryManager.upsert_feed_items(sub["channel_id"], entries) LibraryManager.upsert_feed_items(sub["channel_id"], entries)
LibraryManager.mark_refreshed(sub["channel_id"]) LibraryManager.mark_refreshed(sub["channel_id"])
@@ -73,11 +99,20 @@ class FeedRefreshWorker(QThread):
class FeedPage(QWidget): 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: def __init__(self, router, parent: Optional[QWidget] = None) -> None:
super().__init__(parent) super().__init__(parent)
self._router = router self._router = router
self._refresh_worker: Optional[FeedRefreshWorker] = None self._refresh_worker: Optional[FeedRefreshWorker] = None
self._account_worker: Optional[YtdlpWorker] = 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 = QVBoxLayout(self)
layout.setContentsMargins(8, 8, 8, 8) layout.setContentsMargins(8, 8, 8, 8)
@@ -117,8 +152,25 @@ class FeedPage(QWidget):
side_layout.addWidget(self.subs_list) side_layout.addWidget(self.subs_list)
splitter.addWidget(side) splitter.addWidget(side)
self.grid = VideoCardGrid(router, self) grid_host = QWidget()
splitter.addWidget(self.grid) 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(0, 1)
splitter.setStretchFactor(1, 4) splitter.setStretchFactor(1, 4)
splitter.setSizes([220, 900]) splitter.setSizes([220, 900])
@@ -142,6 +194,8 @@ class FeedPage(QWidget):
LibraryManager.subscribe(str(channel_id), meta.get("title") or str(channel_id), meta["url"], meta.get("avatar_url")) 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.status_label.setText(_("feed.subscribed", title=meta.get("title") or channel_id))
self.reload_subscriptions() self.reload_subscriptions()
self._update_empty_state()
self.subscriptionsChanged.emit()
def reload_subscriptions(self) -> None: def reload_subscriptions(self) -> None:
self.subs_list.clear() self.subs_list.clear()
@@ -158,21 +212,68 @@ class FeedPage(QWidget):
else: else:
self._refresh_local() 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 # --------------------------------------------------------------- local
def _refresh_local(self) -> None: def _refresh_local(self, subs: Optional[List[Dict[str, Any]]] = None, auto: bool = False) -> None:
if self._refresh_worker is not None: if self._refresh_worker is not None:
return return
subs = LibraryManager.subscriptions() if subs is None:
subs = LibraryManager.subscriptions()
if not subs: if not subs:
self.status_label.setText(_("feed.no_subscriptions")) if not auto:
self.status_label.setText(_("feed.no_subscriptions"))
return return
per_channel = int(ConfigManager.get("feed.per_channel_items") or 15) per_channel = int(ConfigManager.get("feed.per_channel_items") or 15)
self.refresh_btn.setEnabled(False) self.refresh_btn.setEnabled(False)
self.status_label.setText(_("feed.refreshing", done=0, total=len(subs))) self.status_label.setText(_("feed.refreshing", done=0, total=len(subs)))
self._done_count = 0 self._done_count = 0
self._failed_count = 0
self._last_error = ""
self._total_count = len(subs) self._total_count = len(subs)
self._refresh_worker = FeedRefreshWorker(subs, per_channel, parent=self) 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.channelDone.connect(self._on_channel_done)
self._refresh_worker.channelFailed.connect(self._on_channel_failed) self._refresh_worker.channelFailed.connect(self._on_channel_failed)
self._refresh_worker.allDone.connect(self._on_refresh_done) self._refresh_worker.allDone.connect(self._on_refresh_done)
@@ -181,22 +282,36 @@ class FeedPage(QWidget):
def _on_channel_done(self, channel_id: str) -> None: def _on_channel_done(self, channel_id: str) -> None:
self._done_count += 1 self._done_count += 1
self.status_label.setText(_("feed.refreshing", done=self._done_count, total=self._total_count)) self.status_label.setText(_("feed.refreshing", done=self._done_count, total=self._total_count))
self._load_cached_feed() # 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: def _on_channel_failed(self, channel_id: str, error: str) -> None:
self._done_count += 1 self._done_count += 1
self.status_label.setText(_("feed.channel_failed", error=error[:120])) 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: def _on_refresh_done(self) -> None:
self.refresh_btn.setEnabled(True) self.refresh_btn.setEnabled(True)
self.status_label.setText(_("feed.refreshed", count=self.grid.card_count())) 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: if self._refresh_worker is not None:
self._refresh_worker.deleteLater() self._refresh_worker.deleteLater()
self._refresh_worker = None self._refresh_worker = None
def _load_cached_feed(self) -> None: @staticmethod
items = LibraryManager.feed_items() def _to_entries(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
entries = [ return [
{ {
"id": it["video_id"], "id": it["video_id"],
"url": it["url"], "url": it["url"],
@@ -207,7 +322,41 @@ class FeedPage(QWidget):
} }
for it in items for it in items
] ]
self.grid.set_entries(entries)
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 # -------------------------------------------------------------- account
@@ -251,8 +400,37 @@ class FeedPage(QWidget):
self._update_mode_availability() self._update_mode_availability()
super().showEvent(event) 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: def _on_mode_changed(self) -> None:
ConfigManager.set("feed.mode", self.mode_combo.currentData()) 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: def _on_sub_activated(self, item: QListWidgetItem) -> None:
sub = item.data(Qt.ItemDataRole.UserRole) sub = item.data(Qt.ItemDataRole.UserRole)
+36 -5
View File
@@ -576,12 +576,17 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
self.feed_page = FeedPage(self.router, self) self.feed_page = FeedPage(self.router, self)
self.browse_page = BrowsePage(self.router, self) self.browse_page = BrowsePage(self.router, self)
# Feed first, Watch last. The feed is what you open the app to see;
# the player is where the other tabs send you, not somewhere you
# start. _tab_index_of resolves by widget identity, so the routing
# methods below are unaffected by this order.
self.main_tabs = SmoothTabWidget(self) self.main_tabs = SmoothTabWidget(self)
self.main_tabs.addTab(self.watch_page, _("main_tabs.watch")) self.main_tabs.addTab(self.feed_page, _("main_tabs.feed"), icons.icon("rss", theme.ICON))
self.main_tabs.addTab(self.search_page, _("main_tabs.search")) self.main_tabs.addTab(self.search_page, _("main_tabs.search"), icons.icon("search", theme.ICON))
self.main_tabs.addTab(self.feed_page, _("main_tabs.feed")) self.main_tabs.addTab(self.browse_page, _("main_tabs.browse"), icons.icon("library", theme.ICON))
self.main_tabs.addTab(self.browse_page, _("main_tabs.browse")) self.main_tabs.addTab(self.download_page, _("main_tabs.downloads"), icons.icon("download", theme.ICON))
self.main_tabs.addTab(self.download_page, _("main_tabs.downloads")) self.main_tabs.addTab(self.watch_page, _("main_tabs.watch"), icons.icon("play", theme.ICON))
self.main_tabs.currentChanged.connect(self._on_main_tab_changed)
self.setCentralWidget(self.main_tabs) self.setCentralWidget(self.main_tabs)
# Fullscreen is driven from here because the chrome it hides -- the tab # Fullscreen is driven from here because the chrome it hides -- the tab
@@ -599,6 +604,25 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
self.router.openChannel.connect(self._route_open_channel) self.router.openChannel.connect(self._route_open_channel)
self.router.openPlaylist.connect(self._route_open_playlist) self.router.openPlaylist.connect(self._route_open_playlist)
self.browse_page.subscribeRequested.connect(self.feed_page.subscribe_channel) self.browse_page.subscribeRequested.connect(self.feed_page.subscribe_channel)
# A new subscription changes what the Feed's empty state should say.
self.feed_page.subscriptionsChanged.connect(self.browse_page.refresh_subscribe_state)
def _on_main_tab_changed(self, index: int) -> None:
"""
Tell a page it has become visible.
Duck-typed rather than isinstance-checked so a page opts in by simply
defining the method. Preferred over showEvent, which also fires on
window restore and on first show.
"""
page = self.main_tabs.stack.widget(index)
hook = getattr(page, "on_tab_activated", None)
if hook is None:
return
try:
hook()
except Exception as e:
logger.debug(f"Tab activation hook failed: {e}")
def _tab_index_of(self, page) -> int: def _tab_index_of(self, page) -> int:
for i in range(self.main_tabs.stack.count()): for i in range(self.main_tabs.stack.count()):
@@ -1211,6 +1235,13 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
except Exception as e: except Exception as e:
logger.debug(f"Watch page shutdown error: {e}") logger.debug(f"Watch page shutdown error: {e}")
# Stop a feed refresh before its QThread is destroyed with it.
if hasattr(self, "feed_page"):
try:
self.feed_page.shutdown()
except Exception as e:
logger.debug(f"Feed page shutdown error: {e}")
# Stop the analysis thread if it's running # Stop the analysis thread if it's running
if hasattr(self, "_analysis_thread") and self._analysis_thread is not None and self._analysis_thread.isRunning(): if hasattr(self, "_analysis_thread") and self._analysis_thread is not None and self._analysis_thread.isRunning():
logger.info("Stopping analysis thread...") logger.info("Stopping analysis thread...")
+55 -10
View File
@@ -1,9 +1,10 @@
from PySide6.QtCore import QEasingCurve, QPropertyAnimation, Qt from PySide6.QtCore import QEasingCurve, QPropertyAnimation, Qt, Signal
from PySide6.QtGui import QPixmap from PySide6.QtGui import QPixmap
from PySide6.QtOpenGLWidgets import QOpenGLWidget from PySide6.QtOpenGLWidgets import QOpenGLWidget
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QFrame, QFrame,
QGraphicsOpacityEffect, QGraphicsOpacityEffect,
QHBoxLayout,
QLabel, QLabel,
QStackedWidget, QStackedWidget,
QTabBar, QTabBar,
@@ -94,6 +95,11 @@ class SmoothTabWidget(QWidget):
A unified Widget that behaves like a QTabWidget but uses smooth fading transitions. A unified Widget that behaves like a QTabWidget but uses smooth fading transitions.
Includes a QTabBar and a FadingStackedWidget. Includes a QTabBar and a FadingStackedWidget.
""" """
#: Emitted after the visible page has actually changed. The QTabBar's own
#: currentChanged is not usable for this: it fires before the stack has
#: switched, and it re-enters through set_current_index.
currentChanged = Signal(int)
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
@@ -102,11 +108,24 @@ class SmoothTabWidget(QWidget):
self.layout.setContentsMargins(0, 0, 0, 0) self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.setSpacing(0) self.layout.setSpacing(0)
# Tab Bar # Tab bar, plus a right-aligned slot for a corner widget. Wrapped in a
self.tab_bar = QTabBar(self) # row so something (the account button) can sit opposite the tabs the
self.tab_bar.setDrawBase(False) # We draw border on content instead # way QTabWidget::setCornerWidget would allow.
self.tab_row = QWidget(self)
tab_row_layout = QHBoxLayout(self.tab_row)
tab_row_layout.setContentsMargins(0, 0, 0, 0)
tab_row_layout.setSpacing(0)
self.tab_bar = QTabBar(self.tab_row)
self.tab_bar.setDrawBase(False) # We draw border on content instead
self.tab_bar.currentChanged.connect(self.set_current_index) self.tab_bar.currentChanged.connect(self.set_current_index)
self.layout.addWidget(self.tab_bar) tab_row_layout.addWidget(self.tab_bar)
tab_row_layout.addStretch(1)
self.corner_widget = None
self._switching = False
self._tab_row_layout = tab_row_layout
self.layout.addWidget(self.tab_row)
# Content Area (Frame) - Mimics QTabWidget::pane # Content Area (Frame) - Mimics QTabWidget::pane
self.content_frame = QFrame(self) self.content_frame = QFrame(self)
@@ -123,15 +142,41 @@ class SmoothTabWidget(QWidget):
self.layout.addWidget(self.content_frame) self.layout.addWidget(self.content_frame)
def addTab(self, widget, label): def addTab(self, widget, label, icon=None):
"""Add a tab with the given widget and label.""" """Add a tab with the given widget, label and optional icon."""
self.stack.addWidget(widget) self.stack.addWidget(widget)
self.tab_bar.addTab(label) if icon is not None:
self.tab_bar.addTab(icon, label)
else:
self.tab_bar.addTab(label)
def setCornerWidget(self, widget):
"""Place a widget at the right-hand end of the tab row."""
if self.corner_widget is not None:
self.corner_widget.setParent(None)
self.corner_widget = widget
if widget is not None:
widget.setParent(self.tab_row)
self._tab_row_layout.addWidget(widget)
def set_current_index(self, index): def set_current_index(self, index):
"""Slot to handle tab bar clicks.""" """Slot to handle tab bar clicks."""
self.tab_bar.setCurrentIndex(index) if index == self.stack.currentIndex() and self.tab_bar.currentIndex() == index:
self.stack.setCurrentIndex(index) return
# setCurrentIndex on the bar emits its own currentChanged, which is
# connected straight back here -- and at that moment the stack has not
# moved yet, so a plain index comparison does not catch the re-entry.
# Without this flag every switch emitted currentChanged twice and each
# page's activation hook ran twice.
if self._switching:
return
self._switching = True
try:
self.tab_bar.setCurrentIndex(index)
self.stack.setCurrentIndex(index)
finally:
self._switching = False
self.currentChanged.emit(index)
def currentWidget(self): def currentWidget(self):
return self.stack.currentWidget() return self.stack.currentWidget()
+7 -2
View File
@@ -735,7 +735,8 @@
"subscribe_tooltip": "Follow this channel so its uploads appear in your Feed", "subscribe_tooltip": "Follow this channel so its uploads appear in your Feed",
"unsubscribe_tooltip": "Stop following this channel", "unsubscribe_tooltip": "Stop following this channel",
"play_all_tooltip": "Queue everything loaded here", "play_all_tooltip": "Queue everything loaded here",
"download_playlist_tooltip": "Open this playlist in Downloads" "download_playlist_tooltip": "Open this playlist in Downloads",
"queued_loaded": "Queued {count} loaded videos — press Load more first to queue the rest"
}, },
"feed": { "feed": {
"mode_local": "Local subscriptions", "mode_local": "Local subscriptions",
@@ -752,6 +753,10 @@
"unsubscribed": "Unsubscribed from {title}", "unsubscribed": "Unsubscribed from {title}",
"open_channel": "Open channel", "open_channel": "Open channel",
"refresh_tooltip": "Fetch the latest uploads from every subscribed channel", "refresh_tooltip": "Fetch the latest uploads from every subscribed channel",
"mode_tooltip": "Where the feed comes from: your local subscriptions, or your signed-in YouTube account" "mode_tooltip": "Where the feed comes from: your local subscriptions, or your signed-in YouTube account",
"refreshed_with_failures": "{count} videos — {failed} channel(s) could not be reached",
"account_needs_refresh": "Press Refresh to load your account feed",
"empty_no_subscriptions": "No subscriptions yet.\n\nFind channels in Search or Browse, then use Subscribe to follow them — their new uploads will appear here.",
"empty_not_refreshed": "Nothing here yet.\n\nPress Refresh to fetch the latest uploads from your subscriptions."
} }
} }
+5
View File
@@ -128,6 +128,11 @@ class ConfigManager:
"mode": "local", # local (per-channel aggregation) | account (cookies) "mode": "local", # local (per-channel aggregation) | account (cookies)
"per_channel_items": 15, "per_channel_items": 15,
"auto_refresh_minutes": 0, # 0 = manual refresh only "auto_refresh_minutes": 0, # 0 = manual refresh only
# Opening the Feed tab refreshes channels not seen for this long.
# 0 disables it. A separate key from auto_refresh_minutes above so
# existing configs, which store that as 0, are not read as opting
# out of a feature that did not exist when they were written.
"auto_refresh_on_open_minutes": 30,
}, },
} }
+20 -7
View File
@@ -165,14 +165,27 @@ class LibraryManager:
conn.commit() conn.commit()
@classmethod @classmethod
def feed_items(cls, limit: int = 120) -> List[Dict[str, Any]]: def feed_items(cls, limit: int = 120, channel_id: Optional[str] = None) -> List[Dict[str, Any]]:
"""
Feed rows, newest first. `channel_id` narrows to one channel, which is
what lets the Feed merge a finished channel's videos into the grid
instead of rebuilding every card.
"""
query = (
"SELECT f.*, s.title AS channel FROM feed_items f "
"LEFT JOIN subscriptions s ON s.channel_id = f.channel_id "
)
params: List[Any] = []
if channel_id is not None:
query += "WHERE f.channel_id = ? "
params.append(str(channel_id))
# published_ts first and only then fetched_at: mixing the two in a
# single COALESCE sorted real publish times against wall-clock fetch
# times, so whichever channel refreshed last floated to the top.
query += "ORDER BY f.published_ts IS NULL, f.published_ts DESC, f.fetched_at DESC LIMIT ?"
params.append(limit)
with cls._lock: with cls._lock:
rows = cls._conn().execute( rows = cls._conn().execute(query, tuple(params)).fetchall()
"SELECT f.*, s.title AS channel FROM feed_items f "
"LEFT JOIN subscriptions s ON s.channel_id = f.channel_id "
"ORDER BY COALESCE(f.published_ts, f.fetched_at) DESC LIMIT ?",
(limit,),
).fetchall()
return [dict(r) for r in rows] return [dict(r) for r in rows]
# ------------------------------------------------------- watch history # ------------------------------------------------------- watch history