Files
SageTube/ytsage/gui/ytsage_gui_browse.py
T
Jaroslav Beneš 8e935c6116 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>
2026-07-25 01:53:44 +02:00

260 lines
10 KiB
Python

"""
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.
"""
import re
from typing import Any, Dict, List, Optional
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"),
]
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)
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:
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)