From 8e935c61168d531b67026ead5e870181f99e3399 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Bene=C5=A1?= Date: Sat, 25 Jul 2026 01:53:44 +0200 Subject: [PATCH] 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 --- ytsage/core/ytsage_client.py | 14 +- ytsage/gui/ytsage_gui_browse.py | 254 ++++++++++++++++++++++++++++++-- ytsage/gui/ytsage_gui_search.py | 2 +- ytsage/languages/en.json | 14 +- 4 files changed, 270 insertions(+), 14 deletions(-) diff --git a/ytsage/core/ytsage_client.py b/ytsage/core/ytsage_client.py index d3f27dd..0131338 100644 --- a/ytsage/core/ytsage_client.py +++ b/ytsage/core/ytsage_client.py @@ -160,9 +160,17 @@ class YtdlpClient: """Full info dict for a single video, including formats[].url.""" return self.run_json(["--dump-single-json", url], timeout=timeout) - def fetch_flat_info(self, url: str, timeout: int = 300) -> Dict[str, Any]: - """Flat info dict (playlist/channel entries without per-video formats).""" - return self.run_json(["--dump-single-json", "--flat-playlist", url], timeout=timeout) + def fetch_flat_info(self, url: str, timeout: int = 300, items: Optional[str] = None) -> Dict[str, Any]: + """Flat info dict (playlist/channel entries without per-video formats). + + items: optional -I range like "1:1" to limit entries when only the + top-level metadata (channel title/id) is needed. + """ + args = ["--dump-single-json", "--flat-playlist"] + if items: + args.extend(["-I", items]) + args.append(url) + return self.run_json(args, timeout=timeout) def fetch_flat_entries( self, url: str, start: int = 1, end: int = 30, timeout: int = 120, use_cache: bool = True diff --git a/ytsage/gui/ytsage_gui_browse.py b/ytsage/gui/ytsage_gui_browse.py index 2847c71..240bf05 100644 --- a/ytsage/gui/ytsage_gui_browse.py +++ b/ytsage/gui/ytsage_gui_browse.py @@ -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) diff --git a/ytsage/gui/ytsage_gui_search.py b/ytsage/gui/ytsage_gui_search.py index adda9e6..737446d 100644 --- a/ytsage/gui/ytsage_gui_search.py +++ b/ytsage/gui/ytsage_gui_search.py @@ -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) diff --git a/ytsage/languages/en.json b/ytsage/languages/en.json index a60c127..8eb10c0 100644 --- a/ytsage/languages/en.json +++ b/ytsage/languages/en.json @@ -674,7 +674,19 @@ "clear_queue": "Clear queue" }, "browse": { - "coming_soon": "Channel & playlist browsing coming soon" + "placeholder": "Paste a channel or playlist URL...", + "open": "Open", + "subscribe": "Subscribe", + "unsubscribe": "Unsubscribe", + "play_all": "Play all", + "download_playlist": "Download", + "tab_videos": "Videos", + "tab_shorts": "Shorts", + "tab_live": "Live", + "loading": "Loading...", + "entry_count": "{count} videos", + "failed": "Failed to load: {error}", + "hint": "Open a channel (youtube.com/@name) or playlist URL, or navigate here from Search." }, "feed": { "coming_soon": "Subscriptions feed coming soon"