Implement channel and playlist browsing

BrowsePage accepts pasted or routed channel/playlist URLs:
- Channels get Videos / Shorts / Live sub-tabs mapped to the channel's
  /videos, /shorts and /streams listings, lazily fetched 24 at a time
  with -I range pagination; channel title and id resolve from a cheap
  -I 1:1 metadata fetch. Subscribe emits subscribeRequested for the
  Feed page to wire up.
- Playlists get a single grid with Play all (bulk-enqueues into the
  Watch queue) and a Download button that deep-links the playlist into
  the Downloads tab.

Workers are parented to their pages and fetch errors surface as status
text instead of crashing (verified against a channel with no videos
tab). fetch_flat_info gains an items="1:1" limiter so metadata probes
no longer enumerate whole channels.

Verified live: kurzgesagt channel (24 cards, title+id resolved) and a
17-video playlist with Play-all queueing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-07-25 01:53:44 +02:00
parent 16be4b4afa
commit 8e935c6116
4 changed files with 270 additions and 14 deletions
+245 -9
View File
@@ -1,23 +1,259 @@
"""
Browse tab - channel and playlist browsing (placeholder, implemented next)
Browse tab - channel and playlist browsing
==========================================
Paste (or route) a channel/playlist URL. Channels get Videos / Shorts / Live
sub-tabs backed by {channel_url}/videos|shorts|streams flat fetches with
-I range pagination; playlists get a single grid with Play all and a
Download deep-link into the Downloads tab.
The Subscribe button emits subscribeRequested; the Feed page owns the
subscription store and completes the wiring.
"""
from typing import Optional
import re
from typing import Any, Dict, List, Optional
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget
from PySide6.QtCore import Signal
from PySide6.QtWidgets import (
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QTabWidget,
QVBoxLayout,
QWidget,
)
from .ytsage_gui_cards import VideoCardGrid
from ..core.ytsage_client import YtdlpWorker
from ..utils.ytsage_localization import _
from ..utils.ytsage_logger import logger
PAGE_SIZE = 24
CHANNEL_URL_RE = re.compile(r"(youtube\.com/(@[\w.-]+|channel/|c/|user/))", re.IGNORECASE)
PLAYLIST_URL_RE = re.compile(r"(youtube\.com/playlist\?|[?&]list=)", re.IGNORECASE)
CHANNEL_TABS = [
("browse.tab_videos", "videos"),
("browse.tab_shorts", "shorts"),
("browse.tab_live", "streams"),
]
class BrowsePage(QWidget):
def _normalize_channel_base(url: str) -> str:
"""Strip a trailing /videos|/shorts|/streams|/featured segment."""
return re.sub(r"/(videos|shorts|streams|featured|playlists|community|about)/?(\?.*)?$", "", url.rstrip("/"))
class _EntriesSection(QWidget):
"""One grid fed by a flat-entries URL with pagination."""
def __init__(self, router, parent: Optional[QWidget] = None) -> None:
super().__init__(parent)
self._router = router
self._worker: Optional[YtdlpWorker] = None
self._url: Optional[str] = None
self._offset = 0
self._loaded_once = False
layout = QVBoxLayout(self)
label = QLabel(_("browse.coming_soon"))
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(label)
layout.setContentsMargins(0, 4, 0, 0)
self.status_label = QLabel("")
self.status_label.setStyleSheet("color: #9aa0a6; padding: 2px;")
layout.addWidget(self.status_label)
self.grid = VideoCardGrid(router, self)
self.grid.loadMoreRequested.connect(self._load_more)
layout.addWidget(self.grid, stretch=1)
def set_url(self, url: Optional[str]) -> None:
self._url = url
self._offset = 0
self._loaded_once = False
self.grid.clear()
self.status_label.setText("")
def ensure_loaded(self) -> None:
if not self._loaded_once and self._url and self._worker is None:
self._loaded_once = True
self._fetch(append=False)
def _load_more(self) -> None:
if self._worker is None and self._url:
self._offset += PAGE_SIZE
self._fetch(append=True)
def _fetch(self, append: bool) -> None:
self.status_label.setText(_("browse.loading"))
url, start, end = self._url, self._offset + 1, self._offset + PAGE_SIZE
self._worker = YtdlpWorker(lambda c: c.fetch_flat_entries(url, start=start, end=end), parent=self)
self._worker.result.connect(lambda entries: self._on_results(entries, append))
self._worker.error.connect(self._on_error)
self._worker.finished.connect(self._on_finished)
self._worker.start()
def _on_results(self, entries: List[Dict[str, Any]], append: bool) -> None:
show_more = len(entries) >= PAGE_SIZE
if append:
self.grid.append_entries(entries, show_load_more=show_more)
else:
self.grid.set_entries(entries, show_load_more=show_more)
self.status_label.setText(_("browse.entry_count", count=self.grid.card_count()))
def _on_error(self, message: str) -> None:
logger.error(f"Browse fetch failed: {message}")
self.status_label.setText(_("browse.failed", error=message[:200]))
def _on_finished(self) -> None:
if self._worker is not None:
self._worker.deleteLater()
self._worker = None
class BrowsePage(QWidget):
subscribeRequested = Signal(dict) # {channel_id?, title?, url}
def __init__(self, router, parent: Optional[QWidget] = None) -> None:
super().__init__(parent)
self._router = router
self._current_url: Optional[str] = None
self._is_channel = False
self._channel_meta: Dict[str, Any] = {}
self._meta_worker: Optional[YtdlpWorker] = None
layout = QVBoxLayout(self)
layout.setContentsMargins(8, 8, 8, 8)
bar = QHBoxLayout()
self.url_input = QLineEdit()
self.url_input.setPlaceholderText(_("browse.placeholder"))
self.url_input.returnPressed.connect(self._open_from_input)
self.url_input.setMinimumHeight(38)
bar.addWidget(self.url_input, stretch=1)
self.open_btn = QPushButton(_("browse.open"))
self.open_btn.setMinimumHeight(38)
self.open_btn.clicked.connect(self._open_from_input)
bar.addWidget(self.open_btn)
layout.addLayout(bar)
header = QHBoxLayout()
self.title_label = QLabel("")
self.title_label.setStyleSheet("font-size: 16px; font-weight: bold;")
header.addWidget(self.title_label, stretch=1)
self.subscribe_btn = QPushButton(_("browse.subscribe"))
self.subscribe_btn.setVisible(False)
self.subscribe_btn.clicked.connect(self._on_subscribe_clicked)
header.addWidget(self.subscribe_btn)
self.playall_btn = QPushButton(_("browse.play_all"))
self.playall_btn.setVisible(False)
self.playall_btn.clicked.connect(self._on_play_all)
header.addWidget(self.playall_btn)
self.download_btn = QPushButton(_("browse.download_playlist"))
self.download_btn.setVisible(False)
self.download_btn.clicked.connect(self._on_download_playlist)
header.addWidget(self.download_btn)
layout.addLayout(header)
self.channel_tabs = QTabWidget()
self._sections: List[_EntriesSection] = []
for label_key, _suffix in CHANNEL_TABS:
section = _EntriesSection(router, self)
self._sections.append(section)
self.channel_tabs.addTab(section, _(label_key))
self.channel_tabs.currentChanged.connect(self._on_channel_tab_changed)
self.channel_tabs.setVisible(False)
layout.addWidget(self.channel_tabs, stretch=1)
self.playlist_section = _EntriesSection(router, self)
self.playlist_section.setVisible(False)
layout.addWidget(self.playlist_section, stretch=1)
self.hint_label = QLabel(_("browse.hint"))
self.hint_label.setStyleSheet("color: #9aa0a6; padding: 40px;")
layout.addWidget(self.hint_label, stretch=1)
# ------------------------------------------------------------ public API
def open_url(self, url: str) -> None:
pass
url = url.strip()
if not url:
return
self.url_input.setText(url)
self._current_url = url
self._is_channel = bool(CHANNEL_URL_RE.search(url)) and not PLAYLIST_URL_RE.search(url)
self.hint_label.setVisible(False)
self._channel_meta = {}
self.title_label.setText(url)
if self._is_channel:
base = _normalize_channel_base(url)
self._current_url = base
self.playlist_section.setVisible(False)
self.channel_tabs.setVisible(True)
self.subscribe_btn.setVisible(True)
self.playall_btn.setVisible(False)
self.download_btn.setVisible(False)
for section, (_k, suffix) in zip(self._sections, CHANNEL_TABS):
section.set_url(f"{base}/{suffix}")
self._sections[self.channel_tabs.currentIndex()].ensure_loaded()
self._fetch_channel_meta(base)
else:
self.channel_tabs.setVisible(False)
self.subscribe_btn.setVisible(False)
self.playlist_section.setVisible(True)
self.playall_btn.setVisible(True)
self.download_btn.setVisible(True)
self.playlist_section.set_url(url)
self.playlist_section.ensure_loaded()
# --------------------------------------------------------------- internal
def _open_from_input(self) -> None:
self.open_url(self.url_input.text())
def _on_channel_tab_changed(self, index: int) -> None:
if 0 <= index < len(self._sections):
self._sections[index].ensure_loaded()
def _fetch_channel_meta(self, base_url: str) -> None:
"""Fetch channel title/id from the videos tab head (cheap, 1 entry)."""
if self._meta_worker is not None:
return
self._meta_worker = YtdlpWorker(lambda c: c.fetch_flat_info(f"{base_url}/videos", items="1:1"), parent=self)
self._meta_worker.result.connect(self._on_channel_meta)
self._meta_worker.error.connect(lambda m: logger.debug(f"Channel meta fetch failed: {m}"))
self._meta_worker.finished.connect(self._on_meta_finished)
self._meta_worker.start()
def _on_channel_meta(self, info: Dict[str, Any]) -> None:
self._channel_meta = {
"channel_id": info.get("channel_id") or info.get("uploader_id"),
"title": info.get("channel") or info.get("uploader") or info.get("title"),
"url": self._current_url,
"avatar_url": None,
}
if self._channel_meta.get("title"):
self.title_label.setText(self._channel_meta["title"])
def _on_meta_finished(self) -> None:
if self._meta_worker is not None:
self._meta_worker.deleteLater()
self._meta_worker = None
def _on_subscribe_clicked(self) -> None:
meta = dict(self._channel_meta) if self._channel_meta.get("url") else {"url": self._current_url}
if not meta.get("title"):
meta["title"] = self.title_label.text()
self.subscribeRequested.emit(meta)
def _on_play_all(self) -> None:
for entry in [c.entry for c in self.playlist_section.grid._cards]:
e = dict(entry)
if not e.get("url") and e.get("id"):
e["url"] = f"https://www.youtube.com/watch?v={e['id']}"
self._router.queueVideo.emit(e)
def _on_download_playlist(self) -> None:
if self._current_url:
self._router.downloadVideo.emit(self._current_url)
+1 -1
View File
@@ -66,7 +66,7 @@ class SearchPage(QWidget):
self.status_label.setText(_("search.searching"))
self.search_btn.setEnabled(False)
query, offset = self._query, self._offset
self._worker = YtdlpWorker(lambda c: c.search(query, n=PAGE_SIZE, offset=offset))
self._worker = YtdlpWorker(lambda c: c.search(query, n=PAGE_SIZE, offset=offset), parent=self)
self._worker.result.connect(lambda entries: self._on_results(entries, append))
self._worker.error.connect(self._on_error)
self._worker.finished.connect(self._on_finished)