""" 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_library_manager import LibraryManager 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.setToolTip(_("browse.open_tooltip")) 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.setToolTip(_("browse.subscribe_tooltip")) 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.setToolTip(_("browse.play_all_tooltip")) 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.setToolTip(_("browse.download_playlist_tooltip")) 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"]) # 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: 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) # 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: cards = list(self.playlist_section.grid._cards) for entry in [c.entry for c in 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) # "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: if self._current_url: self._router.downloadVideo.emit(self._current_url)