|
|
|
@@ -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)
|
|
|
|
|