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:
@@ -160,9 +160,17 @@ class YtdlpClient:
|
|||||||
"""Full info dict for a single video, including formats[].url."""
|
"""Full info dict for a single video, including formats[].url."""
|
||||||
return self.run_json(["--dump-single-json", url], timeout=timeout)
|
return self.run_json(["--dump-single-json", url], timeout=timeout)
|
||||||
|
|
||||||
def fetch_flat_info(self, url: str, timeout: int = 300) -> Dict[str, Any]:
|
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)."""
|
"""Flat info dict (playlist/channel entries without per-video formats).
|
||||||
return self.run_json(["--dump-single-json", "--flat-playlist", url], timeout=timeout)
|
|
||||||
|
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(
|
def fetch_flat_entries(
|
||||||
self, url: str, start: int = 1, end: int = 30, timeout: int = 120, use_cache: bool = True
|
self, url: str, start: int = 1, end: int = 30, timeout: int = 120, use_cache: bool = True
|
||||||
|
|||||||
@@ -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.QtCore import Signal
|
||||||
from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget
|
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_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:
|
def __init__(self, router, parent: Optional[QWidget] = None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._router = router
|
self._router = router
|
||||||
|
self._worker: Optional[YtdlpWorker] = None
|
||||||
|
self._url: Optional[str] = None
|
||||||
|
self._offset = 0
|
||||||
|
self._loaded_once = False
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
label = QLabel(_("browse.coming_soon"))
|
layout.setContentsMargins(0, 4, 0, 0)
|
||||||
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
self.status_label = QLabel("")
|
||||||
layout.addWidget(label)
|
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:
|
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)
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ class SearchPage(QWidget):
|
|||||||
self.status_label.setText(_("search.searching"))
|
self.status_label.setText(_("search.searching"))
|
||||||
self.search_btn.setEnabled(False)
|
self.search_btn.setEnabled(False)
|
||||||
query, offset = self._query, self._offset
|
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.result.connect(lambda entries: self._on_results(entries, append))
|
||||||
self._worker.error.connect(self._on_error)
|
self._worker.error.connect(self._on_error)
|
||||||
self._worker.finished.connect(self._on_finished)
|
self._worker.finished.connect(self._on_finished)
|
||||||
|
|||||||
@@ -674,7 +674,19 @@
|
|||||||
"clear_queue": "Clear queue"
|
"clear_queue": "Clear queue"
|
||||||
},
|
},
|
||||||
"browse": {
|
"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": {
|
"feed": {
|
||||||
"coming_soon": "Subscriptions feed coming soon"
|
"coming_soon": "Subscriptions feed coming soon"
|
||||||
|
|||||||
Reference in New Issue
Block a user