import copy import datetime import json import logging import os import shutil import subprocess import sys import threading import traceback import webbrowser from pathlib import Path from yt_dlp.networking.exceptions import HTTPError from typing import Callable import click import requests from yt_dlp import YoutubeDL from utils import save_json, read_json, check_url_scheme, parser_url_scheme, find_mpv_path, find_http_status # Qt from PySide6.QtWidgets import ( QApplication, QMainWindow, QWidget, QDialog, QLabel, QPushButton, QFrame, QScrollArea, QVBoxLayout, QHBoxLayout, QGridLayout, QMessageBox, QInputDialog, QSizePolicy, QFileDialog, ) from PySide6.QtCore import Qt, QTimer, Signal, QObject, QThread, QSize, QUrl from PySide6.QtGui import QPixmap, QFont, QImage, QIcon, QDesktopServices VERSION = "Alpha-2-QtTest" PROGRAM_DIR = Path(__file__).parent work_dir = None logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) YT_PLAYLISTS_URL = "https://www.youtube.com/feed/playlists" def sanitize_filename(value: str): invalid_chars = '<>:"/\\|?*' return "".join("_" if char in invalid_chars else char for char in value) def fetch_video_details(video_url: str, opts): opts = copy.deepcopy(opts) opts.update({"extract_flat": True, }) try: with YoutubeDL(opts) as ydl: info = ydl.extract_info(video_url, download=False) except Exception as e: logger.error(f"Unable to fetch video details: {e}\nURL:{video_url}") return None return info def resolve_video_page_url(video: dict): return ( video.get("webpage_url") or video.get("url") or (f"https://www.youtube.com/watch?v={video.get('id')}" if video.get("id") else None) ) def download_file(url, dest: Path): try: dest.parent.mkdir(parents=True, exist_ok=True) with requests.get(url, stream=True) as r: with dest.open(mode="wb") as f: for chunk in r.iter_content(chunk_size=8192): f.write(chunk) return True except Exception as e: logger.error("Unable to download file: %s", e) return False fetch_playlist_lock = threading.Lock() class PlaylistFetchException(Exception): def __init__(self, url, title, reason, enable_option=False, options=None, no_cancel=False): super().__init__() self.url = url self.title = title self.reason = reason self.qt_options = { "enable": enable_option, "options": options, "noCancelOption": no_cancel, } def create_messagebox(self, master): box = QMessageBox(master) box.setWindowTitle(self.title) box.setText(self.reason or self.title) box.setIcon(QMessageBox.Icon.Critical) is_custom_option_enabled = self.qt_options["enable"] is True and type(self.qt_options["options"]) is list if is_custom_option_enabled: for option in self.qt_options["options"]: label = option.get("label", None) action_func = option.get("action", None) role = option.get("role", QMessageBox.ButtonRole.AcceptRole) # Some type check if label is None or (action_func is None or not callable(action_func)): logger.warning(f"Skipping {option} ({action_func}) because its label or action is not set yet or not callable.") continue if not isinstance(role, QMessageBox.ButtonRole): logger.warning(f"Skipping {option} ({role}) because its role type is not QMessageBox.ButtonRole.") continue btn = box.addButton(label, role) option["button"] = btn if not self.qt_options["noCancelOption"]: box.addButton("Cancel", QMessageBox.ButtonRole.RejectRole) elif not is_custom_option_enabled: box.addButton(QMessageBox.StandardButton.Ok) box.exec() clicked = box.clickedButton() # If the option's button is clicked, Call the target action function if is_custom_option_enabled: for option in self.qt_options["options"]: btn = option.get("button") action_func = option.get("action") if clicked == btn: return action_func() return None def fetch_playlists_data(url_schema, cookie_file_path: Path, opts, callback: Callable): check_url_scheme(os.path.abspath(__file__), work_dir) error = None if url_schema is not None: try: parser_url_scheme(url_schema, cookie_path=cookie_file_path) except Exception as e: logger.error("Unable to parse URL scheme: %s", e) input("Press enter to continue...") playlists_data = None with fetch_playlist_lock: opts = copy.deepcopy(opts) opts.update({ 'extract_flat': True, 'skip_download': True, }) try: with YoutubeDL(opts) as ydl: playlists_data = ydl.extract_info(YT_PLAYLISTS_URL, download=False) except Exception as e: status_code = find_http_status(e) if status_code == 401: error = "Cookie are expired. Reopen the tool again in browser!", None elif status_code == 403: error = "Server forbidden. Did you logged in?", None elif status_code == 429: error = "Too many requests. Try again later.", None elif status_code == 503: error = "Server unavailable. Try again later.", None elif status_code == 522: error = "Connection timed out.", None else: error = (f"Unexpected error: {e} (Type: {type(e)})\n" f"Traceback: {traceback.format_exc()}", None) if playlists_data is None: logger.error(f"Unable to fetch playlists: {error}") playlists_data = {"entries": []} pf_exec = None if error is not None: reason = error[0] if isinstance(error, tuple) else error pf_exec = PlaylistFetchException( url=YT_PLAYLISTS_URL, title="Playlists Fetch Error", reason=reason, enable_option=True, options=[ {"label": "Reopen browser", "action": lambda: webbrowser.open(YT_PLAYLISTS_URL)}, ], ) callback(playlists_data, pf_exec) class PlaylistSaver(QApplication): DEFAULT_PROFILE_NAME = "default" THUMBNAIL_SIZE = QSize(144, 81) mpv_processes = {} def __init__(self, url_schema=None, cookie_file: str | None = None): super().__init__() self.setApplicationName("PlaylistSaver") self.url_schema = url_schema self.cookie_file = cookie_file # Path self.save_path = Path(work_dir, "save") self.temp_path = Path(work_dir, "temp") self.profiles_meta_path = Path(self.save_path, "profiles.json") self.profiles_path = Path(self.save_path, "profiles") self.cookie_path = Path(self.profiles_path, self.DEFAULT_PROFILE_NAME, "cookies.txt") # Profile self.active_profile_name = self.DEFAULT_PROFILE_NAME # Data self.playlists: dict[str, list] = { "entries": [] } self.window = self.MainWindow(self) self.init() self.window.show() class MainWindow(QMainWindow): def __init__(self, parent): super().__init__() self.parent = parent self.child_windows = [] self.setWindowTitle("PlaylistSaver") self.resize(700, 800) # Central widget self.central_widget = QWidget(self) self.setCentralWidget(self.central_widget) # Layouts self.main_layout = QHBoxLayout(self.central_widget) self.main_layout.setContentsMargins(12, 12, 12, 12) self.main_layout.setSpacing(12) self.right_layout = QVBoxLayout() self.right_layout.setContentsMargins(12, 12, 12, 12) self.right_layout.setSpacing(10) self.playlists_layout = QVBoxLayout() self.playlists_layout.setContentsMargins(10, 10, 10, 10) self.playlists_layout.setSpacing(8) # Frame self.playlists_scroll = QScrollArea(self) self.playlists_scroll.setWidgetResizable(True) self.playlists_scroll.setFrameShape(QFrame.NoFrame) self.playlists_frame = QFrame(self.playlists_scroll) self.playlists_frame.setLayout(self.playlists_layout) self.playlists_scroll.setWidget(self.playlists_frame) self.playlists_scroll.setMinimumWidth(400) self.right_panel = QFrame(self.central_widget) self.right_panel.setObjectName("rightPanel") self.right_panel.setFixedWidth(190) self.right_panel.setLayout(self.right_layout) # Items self.profile_label = QLabel(f"Current Profile: {self.parent.get_active_profile_name()}",) self.profile_label.setObjectName("profileLabel") self.profile_label.setWordWrap(True) self.loading_label = QLabel("Loading...") self.loading_label.setObjectName("loadingLabel") self.loading_label.setAlignment(Qt.AlignCenter) self.switch_profile_btn = QPushButton("Switch Profile") self.refresh_playlists_btn = QPushButton("Refresh playlists") self.switch_profile_btn.setMinimumHeight(34) self.refresh_playlists_btn.setMinimumHeight(34) self.version_label = QLabel(f"v{VERSION}") self.version_label.setObjectName("versionLabel") self.playlist_items_layout = QVBoxLayout() self.playlist_items_layout.setContentsMargins(0, 0, 0, 0) self.playlist_items_layout.setSpacing(8) self.playlists_layout.addWidget(self.loading_label, 0, Qt.AlignHCenter) self.playlists_layout.addLayout(self.playlist_items_layout) # Bind item self.left_layout = QVBoxLayout() self.left_layout.setContentsMargins(0, 0, 0, 0) self.left_layout.setSpacing(10) self.left_layout.addWidget(self.playlists_scroll, 1) self.right_layout.addWidget(self.profile_label) self.right_layout.addWidget(self.switch_profile_btn) self.right_layout.addWidget(self.refresh_playlists_btn) self.right_layout.addStretch(1) self.right_layout.addWidget(self.version_label, 0, Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignHCenter) self.main_layout.addLayout(self.left_layout, 1) self.main_layout.addWidget(self.right_panel) self.setStyleSheet(""" QMainWindow { background-color: #202020; } QScrollArea, QFrame { background-color: #242424; } QLabel { background-color: transparent; } #rightPanel { background-color: #2b2b2b; border: 1px solid #3c3c3c; border-radius: 8px; } #profileLabel { color: #f0f0f0; font-weight: 600; padding-bottom: 6px; } #loadingLabel { color: #a8a8a8; padding: 4px; } QPushButton { background-color: #3a3a3a; color: #f0f0f0; border: 1px solid #505050; border-radius: 6px; padding: 7px 10px; } QPushButton:hover { background-color: #464646; } QPushButton:pressed { background-color: #303030; } """) # Bind signals function self.signals = self.PlaylistWorkerSignals() self.signals.playlists_ready.connect(self.build_ui) self.signals.error.connect(self.show_error) self.signals.playlist_fetch_error.connect(self.show_playlist_fetch_error) self.switch_profile_btn.clicked.connect(self.open_switch_profile_dialog) self.refresh_playlists_btn.clicked.connect(self.parent.reload_playlists) class PlaylistWorkerSignals(QObject): # Signals playlists_ready = Signal(dict) profile_delete = Signal(str) # playlist playlist_clicked = Signal(dict) video_clicked = Signal(str) # error error = Signal(str) playlist_fetch_error = Signal(object) def build_ui(self, playlists: dict[str, list]): self.clear_layout(self.playlist_items_layout) playlists = playlists.get("entries", []) playlists = [playlist for playlist in playlists if playlist is not None] # ignore None item for playlist in playlists: plist_id = playlist.get("id", None) title = playlist.get("title", "Title not found") item = self.PlaylistItem( self.playlists_frame, playlist, title=title, ) if len(playlist.get("thumbnails", [])) > 0: thumbnail_url = playlist.get("thumbnails", [])[0].get('url', None) thumbnail_resolution = playlist.get("thumbnails", [])[0].get('resolution', None) thumbnail_path = Path(work_dir, "temp", "thumbnails", f"thumbnail_{plist_id}_{thumbnail_resolution}.jpg") result = download_file(thumbnail_url, thumbnail_path) if result or (thumbnail_path.exists() and thumbnail_path.is_file()): item.set_thumbnail(thumbnail_path) item.clicked.connect(self.open_playlist_window) self.playlist_items_layout.addWidget(item) self.loading_label.setText("Done") @staticmethod def clear_layout(layout): while layout.count(): item = layout.takeAt(0) child_layout = item.layout() widget = item.widget() if child_layout: PlaylistSaver.MainWindow.clear_layout(child_layout) if widget: widget.deleteLater() def open_playlist_window(self, playlist: dict): window = self.PlaylistWindow(self, playlist) self.child_windows.append(window) window.destroyed.connect( lambda _obj=None, w=window: self.child_windows.remove(w) if w in self.child_windows else None) window.show() def show_error(self, message: str): QMessageBox.critical(self, "Error", message) def show_playlist_fetch_error(self, error: PlaylistFetchException): error.create_messagebox(self) def open_video_player(self, video: dict): video_url = resolve_video_page_url(video) if not video_url: QMessageBox.critical(self, "Error", "Unable to resolve video URL.") return dialog = self.PlayerDialog(self, video) self.child_windows.append(dialog) dialog.destroyed.connect( lambda _obj=None, w=dialog: self.child_windows.remove(w) if w in self.child_windows else None) dialog.show() def open_switch_profile_dialog(self): dialog = self.SwitchProfileDialog(self) dialog.profile_changed.connect(self.apply_profile_switch) dialog.exec() def apply_profile_switch(self, profile_name: str): self.parent.set_active_profile(profile_name) self.profile_label.setText(f"Current Profile: {profile_name}") class PlaylistItem(QFrame): clicked = Signal(dict) def __init__(self, parent, data, title: str = "", thumbnail_path: str | Path | None = None): super().__init__(parent) self.setObjectName("playlistItem") self.original_thumbnail = None self.layout = QHBoxLayout(self) self.layout.setContentsMargins(12, 10, 12, 10) self.layout.setSpacing(12) self.setMinimumHeight(92) self.setMaximumHeight(120) self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) # item self.title_label = QLabel(title, self) self.title_label.setObjectName("playlistTitle") self.title_label.setWordWrap(True) self.title_label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft) self.title_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) self.thumbnail_label = QLabel(self) self.thumbnail_label.setObjectName("thumbnailLabel") self.thumbnail_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.thumbnail_label.setFixedSize(PlaylistSaver.THUMBNAIL_SIZE) self.thumbnail_label.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) # bind self.layout.addWidget(self.title_label, 1) self.layout.addWidget(self.thumbnail_label) if thumbnail_path: self.set_thumbnail(thumbnail_path) self.setStyleSheet(""" QFrame#playlistItem { background-color: #2b2b2b; border: 1px solid #3c3c3c; border-radius: 8px; } QFrame#playlistItem:hover { background-color: #4f0202; border-color: #555555; } QLabel { background-color: transparent; border: none; color: #f0f0f0; } #playlistTitle { font-weight: 600; } #thumbnailLabel { background-color: #1f1f1f; border: 1px solid #3a3a3a; border-radius: 6px; } """) # Playlist data self.data = data @property def app(self): return self.parent() def set_thumbnail(self, thumbnail_path: str | Path): pixmap = QPixmap(str(thumbnail_path)) if pixmap.isNull(): self.thumbnail_label.hide() return self.original_thumbnail = pixmap self.thumbnail_label.show() self.update_thumbnail_size() def update_thumbnail_size(self): if not self.original_thumbnail: return scaled = self.original_thumbnail.scaled( self.thumbnail_label.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation, ) self.thumbnail_label.setPixmap(scaled) def resizeEvent(self, event): super().resizeEvent(event) self.update_thumbnail_size() def mousePressEvent(self, event): if event.button() == Qt.MouseButton.LeftButton: self.clicked.emit(self.data) super().mousePressEvent(event) class VideoItem(QFrame): clicked = Signal(dict) def __init__(self, parent, video: dict, index: int = 0, thumbnail_path: str | Path | None = None): super().__init__(parent) self.setObjectName("videoItem") self.original_thumbnail = None self.video = video self.id = video.get("id") self.url = resolve_video_page_url(video) title = video.get("title", "Unnamed Video") if index: title = f"{index}. {title}" if video.get("duration"): duration = str(datetime.timedelta(seconds=video.get("duration"))) else: duration = "Fetching duration..." self.layout = QHBoxLayout(self) self.layout.setContentsMargins(12, 10, 12, 10) self.layout.setSpacing(12) self.setMinimumHeight(92) self.setMaximumHeight(120) self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) # item self.title_label = QLabel(title, self) self.title_label.setObjectName("videoTitle") self.title_label.setWordWrap(True) self.title_label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft) self.title_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) self.duration_label = QLabel(duration, self) self.duration_label.setObjectName("durationLabel") self.duration_label.setAlignment(Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignLeft) self.download_button = QPushButton("Download", self) self.download_button.setObjectName("downloadButton") self.thumbnail_label = QLabel(self) self.thumbnail_label.setObjectName("thumbnailLabel") self.thumbnail_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.thumbnail_label.setFixedSize(PlaylistSaver.THUMBNAIL_SIZE) self.thumbnail_label.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) self.thumbnail_label.hide() # bind self.text_layout = QVBoxLayout() self.text_layout.setContentsMargins(0, 0, 0, 0) self.text_layout.setSpacing(6) self.text_layout.addWidget(self.title_label, 1) self.text_layout.addWidget(self.duration_label) self.text_layout.addWidget(self.download_button, 1, Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignRight) self.layout.addLayout(self.text_layout, 1) self.layout.addWidget(self.thumbnail_label) if thumbnail_path: self.set_thumbnail(thumbnail_path) self.setStyleSheet(""" QFrame#videoItem { background-color: #2b2b2b; border: 1px solid #3c3c3c; border-radius: 8px; } QFrame#videoItem:hover { background-color: #333333; border-color: #555555; } QLabel { background-color: transparent; border: none; color: #f0f0f0; } #videoTitle { font-weight: 600; } #thumbnailLabel { background-color: #1f1f1f; border: 1px solid #3a3a3a; border-radius: 6px; } #downloadButton { max-height: 30px; } """) self.setCursor(Qt.CursorShape.PointingHandCursor) def set_thumbnail(self, thumbnail_path: str | Path): pixmap = QPixmap(str(thumbnail_path)) if pixmap.isNull(): self.thumbnail_label.hide() return self.original_thumbnail = pixmap self.thumbnail_label.show() self.update_thumbnail_size() def update_thumbnail_size(self): if not self.original_thumbnail: return scaled = self.original_thumbnail.scaled( self.thumbnail_label.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation, ) self.thumbnail_label.setPixmap(scaled) def resizeEvent(self, event): super().resizeEvent(event) self.update_thumbnail_size() def mousePressEvent(self, event): if event.button() == Qt.MouseButton.LeftButton: self.clicked.emit(self.video) super().mousePressEvent(event) def set_duration(self, duration: str): self.duration_label.setText(duration) class PlaylistWindow(QDialog): class LoadSignals(QObject): playlist_ready = Signal(dict) error = Signal(str) def __init__(self, parent, playlist: dict): QDialog.__init__(self, parent) self.parent = parent self.playlist = playlist self.setWindowTitle(playlist.get("title", "Playlist")) self.resize(760, 640) self.signals = self.LoadSignals() self.signals.playlist_ready.connect(self.render_videos) self.signals.error.connect(self.show_error) # Layout self.layout = QVBoxLayout(self) self.layout.setContentsMargins(16, 16, 16, 16) self.layout.setSpacing(12) self.header_label = QLabel(playlist.get("title", "Playlist"), self) self.header_label.setObjectName("playlistTitle") self.header_label.setWordWrap(True) self.info_label = QLabel("Loading playlist videos...", self) self.info_label.setObjectName("playlistInfo") self.content_layout = QHBoxLayout() self.content_layout.setSpacing(12) # Scroll area and frame for video items self.scroll_area = QScrollArea(self) self.scroll_area.setWidgetResizable(True) self.scroll_area.setFrameShape(QFrame.NoFrame) self.list_frame = QFrame(self.scroll_area) self.list_frame.setObjectName("playlistFrame") self.list_layout = QVBoxLayout(self.list_frame) self.list_layout.setContentsMargins(8, 8, 8, 8) self.list_layout.setSpacing(8) self.scroll_area.setWidget(self.list_frame) self.control_panel = QFrame(self) self.control_panel.setObjectName("playlistControls") self.control_panel.setFixedWidth(150) self.control_layout = QVBoxLayout(self.control_panel) self.control_layout.setContentsMargins(12, 12, 12, 12) self.control_layout.setSpacing(10) self.reload_playlist_btn = QPushButton("Reload") self.reload_playlist_btn.setMinimumHeight(34) self.reload_playlist_btn.clicked.connect(self.render_playlist) self.control_layout.addWidget(self.reload_playlist_btn) self.control_layout.addStretch(1) self.content_layout.addWidget(self.scroll_area, 1) self.content_layout.addWidget(self.control_panel) self.layout.addWidget(self.header_label) self.layout.addWidget(self.info_label) self.layout.addLayout(self.content_layout, 1) self.render_playlist() self.setStyleSheet(""" QDialog { background-color: #202020; } QScrollArea, #playlistFrame { background-color: #242424; } #playlistTitle { color: #f0f0f0; font-size: 20px; font-weight: 700; } #playlistInfo { color: #a8a8a8; } #playlistControls { background-color: #2b2b2b; border: 1px solid #3c3c3c; border-radius: 8px; } QLabel { background-color: transparent; color: #f0f0f0; } QPushButton { background-color: #3a3a3a; color: #f0f0f0; border: 1px solid #505050; border-radius: 6px; padding: 7px 10px; } QPushButton:hover { background-color: #464646; } QPushButton:disabled { color: #999999; background-color: #303030; } """) @property def app(self): return self.parent.parent def render_playlist(self): self.reload_playlist_btn.setEnabled(False) self.info_label.setText("Loading playlist videos...") PlaylistSaver.MainWindow.clear_layout(self.list_layout) threading.Thread(target=self.load_playlist_worker, daemon=True).start() def load_playlist_worker(self): try: playlist_data = self.app.fetch_playlist_videos(self.playlist) except Exception as e: logger.error("Unable to load playlist videos: %s", e) self.signals.error.emit(str(e)) return if playlist_data is None: playlist_data = {"entries": []} self.signals.playlist_ready.emit(playlist_data) def render_videos(self, playlist_data: dict): PlaylistSaver.MainWindow.clear_layout(self.list_layout) self.reload_playlist_btn.setEnabled(True) entries = [entry for entry in playlist_data.get("entries", []) if entry is not None] self.info_label.setText(f"{len(entries)} videos") if not entries: self.list_layout.addWidget(QLabel("No videos found.", self.list_frame)) self.list_layout.addStretch(1) return for index, video in enumerate(entries, start=1): thumbnail_path = self.get_video_thumbnail_path(video) item = PlaylistSaver.MainWindow.VideoItem(self.list_frame, video, index, thumbnail_path) item.clicked.connect(self.parent.open_video_player) self.list_layout.addWidget(item) self.list_layout.addStretch(1) def show_error(self, message: str): self.reload_playlist_btn.setEnabled(True) self.info_label.setText("Unable to load playlist.") QMessageBox.critical(self, "Error", f"Unable to load playlist: {message}") def get_video_thumbnail_path(self, video: dict): thumbnails = video.get("thumbnails") or [] if not thumbnails: return None thumbnail = thumbnails[0] thumbnail_url = thumbnail.get("url") if not thumbnail_url: return None thumbnail_resolution = thumbnail.get("resolution", "unknown") video_id = video.get("id") or "unknown" thumbnail_path = Path(work_dir, "temp", "thumbnails", f"thumbnail_{video_id}_{thumbnail_resolution}.jpg") if thumbnail_path.exists(): return thumbnail_path if download_file(thumbnail_url, thumbnail_path): return thumbnail_path return None class PlayerDialog(QDialog): status_changed = Signal(str) def __init__(self, parent, video: dict): QDialog.__init__(self, parent) self.parent = parent self.video = video self.video_url = resolve_video_page_url(video) self.process = None self.setWindowTitle(video.get("title", "Video")) self.resize(440, 190) self.status_changed.connect(self.set_status) self.layout = QVBoxLayout(self) self.layout.setContentsMargins(16, 16, 16, 16) self.layout.setSpacing(10) self.title_label = QLabel(video.get("title", "Video"), self) self.title_label.setWordWrap(True) self.status_label = QLabel("Launching mpv...", self) self.url_label = QLabel(self.video_url or "", self) self.url_label.setWordWrap(True) self.close_btn = QPushButton("Close Player", self) self.close_btn.clicked.connect(self.close) self.layout.addWidget(self.title_label) self.layout.addWidget(self.status_label) self.layout.addWidget(self.url_label) self.layout.addWidget(self.close_btn) self.setStyleSheet(""" QDialog { background-color: #202020; } QLabel { background-color: transparent; color: #f0f0f0; } QPushButton { background-color: #3a3a3a; color: #f0f0f0; border: 1px solid #505050; border-radius: 6px; padding: 7px 10px; } QPushButton:hover { background-color: #464646; } """) threading.Thread(target=self.run_mpv, daemon=True).start() def set_status(self, message: str): self.status_label.setText(message) def run_mpv(self): mpv_path = find_mpv_path(PROGRAM_DIR) if not mpv_path: self.status_changed.emit("mpv not found in bin/ or PATH.") return try: self.process = subprocess.Popen( [ mpv_path, # "--force-window=immediate", "--focus-on=open", self.video_url, ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), ) except Exception as e: logger.error("Unable to start mpv: %s", e) self.status_changed.emit(f"Unable to start mpv: {e}") return self.parent.parent.mpv_processes[self] = self.process self.status_changed.emit("Playing in mpv...") self.process.wait() self.parent.parent.mpv_processes.pop(self, None) self.status_changed.emit("Playback finished.") def closeEvent(self, event): process = self.parent.parent.mpv_processes.pop(self, None) if process and process.poll() is None: process.terminate() event.accept() class SwitchProfileDialog(QDialog): profile_changed = Signal(str) def __init__(self, parent): QDialog.__init__(self, parent) self.parent = parent self.setWindowTitle("Switch Profile") self.resize(420, 420) # Layout self.layout = QVBoxLayout(self) self.layout.setContentsMargins(16, 16, 16, 16) self.layout.setSpacing(12) # Items self.current_profile_label = QLabel(f"Current: {self.app.get_active_profile_name()}") self.current_profile_label.setWordWrap(True) self.current_profile_label.setObjectName("currentProfile") # Scroll are and frame (for profile item) self.scroll_area = QScrollArea(self) self.scroll_area.setWidgetResizable(True) self.scroll_area.setFrameShape(QFrame.NoFrame) self.list_frame = QFrame(self.scroll_area) self.list_frame.setObjectName("profileList") self.list_layout = QVBoxLayout(self.list_frame) self.list_layout.setContentsMargins(8, 8, 8, 8) self.list_layout.setSpacing(8) self.scroll_area.setWidget(self.list_frame) self.create_profile_btn = QPushButton("Create Profile") self.create_profile_btn.setMinimumHeight(34) self.create_profile_btn.clicked.connect(self.create_profile) self.layout.addWidget(self.current_profile_label) self.layout.addWidget(self.scroll_area, 1) self.layout.addWidget(self.create_profile_btn) self.render_profiles() self.setStyleSheet(""" QDialog { background-color: #202020; } QScrollArea, #profileList { background-color: #242424; } #currentProfile { background-color: transparent; color: #f0f0f0; font-weight: 600; padding-bottom: 4px; } QFrame#profileRow { background-color: #2b2b2b; border: 1px solid #3c3c3c; border-radius: 8px; } QLabel { background-color: transparent; color: #f0f0f0; } QPushButton { background-color: #3a3a3a; color: #f0f0f0; border: 1px solid #505050; border-radius: 6px; padding: 7px 10px; } QPushButton:hover { background-color: #464646; } QPushButton:disabled { color: #999999; background-color: #303030; } """) @property def app(self): return self.parent.parent def clear_profiles(self): while self.list_layout.count(): item = self.list_layout.takeAt(0) widget = item.widget() if widget: widget.deleteLater() class ProfileRow(QFrame): def __init__(self, parent, profile_name: str, active_profile: str, switch_profile, delete_profile): QFrame.__init__(self, parent) self.setObjectName("profileRow") self.row_layout = QHBoxLayout(self) self.row_layout.setContentsMargins(10, 8, 10, 8) self.row_layout.setSpacing(8) self.name_label = QLabel(profile_name) self.name_label.setWordWrap(True) self.switch_btn = QPushButton("Current" if profile_name == active_profile else "Switch") self.switch_btn.setMinimumWidth(88) self.switch_btn.setEnabled(profile_name != active_profile) self.switch_btn.clicked.connect(lambda _checked=False, name=profile_name: switch_profile(name)) self.delete_btn = QPushButton("Delete") self.delete_btn.setMinimumWidth(88) self.delete_btn.setDisabled(profile_name == "Default") self.delete_btn.clicked.connect(lambda _checked=False, name=profile_name: delete_profile(name)) self.row_layout.addWidget(self.name_label, 1) self.row_layout.addWidget(self.switch_btn) self.row_layout.addWidget(self.delete_btn) def render_profiles(self): self.clear_profiles() meta = self.app.load_profiles_meta() active_profile = meta["active_profile"] for profile_name in meta["profiles"]: row = self.ProfileRow(self, profile_name, active_profile, self.switch_profile, self.delete_profile) self.list_layout.addWidget(row) self.list_layout.addStretch(1) def switch_profile(self, profile_name: str): self.profile_changed.emit(profile_name) self.accept() def delete_profile(self, profile_name: str): meta = self.app.load_profiles_meta() if profile_name == meta["active_profile"]: QMessageBox.critical(self, "Error", "Cannot delete the active profile.") return if profile_name == self.app.DEFAULT_PROFILE_NAME: QMessageBox.critical(self, "Error", "Cannot delete the default profile.") return reply = QMessageBox.question( self, "Delete Profile", f"Delete profile '{profile_name}'?", QMessageBox.Yes | QMessageBox.No, QMessageBox.No, ) if reply != QMessageBox.Yes: return meta["profiles"] = [ name for name in meta["profiles"] if name != profile_name ] self.app.save_profiles_meta(meta) self.render_profiles() def create_profile(self): profile_name, ok = QInputDialog.getText(self, "Create Profile", "Profile name:") if not ok: return profile_name = profile_name.strip() if not profile_name: QMessageBox.critical(self, "Error", "Profile name cannot be empty.") return self.app.ensure_profile_exists(profile_name) self.render_profiles() def init(self): self.initialize_profiles() # Start background thread after root is ready for UI callbacks. self.reload_playlists() def reload_playlists(self): self.window.loading_label.setText("Loading playlists...") threading.Thread( target=fetch_playlists_data, args=(self.url_schema, self.get_profile_cookie_path(self.active_profile_name), self.get_ydl_opts(), self.on_data_ready), daemon=True, ).start() # # Profile # def initialize_profiles(self): try: self.profiles_path.parent.mkdir(parents=True, exist_ok=True) except Exception as e: logger.error("Unable to create profiles folder:", e) QMessageBox.critical(self.window, "Error", "Unable to create profiles folder.\n" f"At path: {self.profiles_path.as_posix()}\n" f"Reason: {e}\n" f"Solution: Try to remove profiles folder manually and try again.") active_name = self.get_active_profile_name() self.ensure_profile_exists(active_name) self.set_active_profile(active_name) def ensure_profile_exists(self, profile_name: str): meta = self.load_profiles_meta() profiles = meta["profiles"] if profile_name not in profiles: profiles.append(profile_name) meta["profiles"] = profiles self.save_profiles_meta(meta) self.get_profile_dir(profile_name).parent.mkdir(parents=True, exist_ok=True) def get_active_profile_name(self): meta = self.load_profiles_meta() self.active_profile_name = meta.get("active_profile", None) return self.active_profile_name def load_profiles_meta(self): meta = read_json(self.profiles_meta_path) if not meta: return { "active_profile": self.DEFAULT_PROFILE_NAME, "profiles": [self.DEFAULT_PROFILE_NAME], } profiles = meta.get("profiles") or [self.DEFAULT_PROFILE_NAME] active_profile = meta.get("active_profile") or profiles[0] if active_profile not in profiles: profiles.insert(0, active_profile) return { "active_profile": active_profile, "profiles": profiles, } def set_active_profile(self, profile_name: str): meta = self.load_profiles_meta() profiles = meta["profiles"] if profile_name not in profiles: profiles.append(profile_name) meta["profiles"] = profiles meta["active_profile"] = profile_name self.save_profiles_meta(meta) self.active_profile_name = profile_name self.cookie_path = self.get_profile_cookie_path(profile_name) self.cookie_path.parent.mkdir(parents=True, exist_ok=True) def save_profiles_meta(self, meta: dict): save_json(meta, self.profiles_meta_path) def delete_profile(self, profile_name: str): meta = self.load_profiles_meta() if not profile_name in meta["profiles"]: return meta["profiles"].remove(profile_name) self.save_profiles_meta(meta) # # Path # def get_profile_cookie_path(self, profile_name: str): return Path(self.get_profile_dir(profile_name), "cookies.txt") def get_profile_dir(self, profile_name: str): return Path(self.profiles_path, sanitize_filename(profile_name)) def get_profile_temp_dir(self, profile_name: str | None = None): profile_name = profile_name or self.active_profile_name return self.temp_path / sanitize_filename(profile_name) def get_playlist_cache_path(self, playlist_id: str): safe_id = sanitize_filename(playlist_id or "unknown") return self.get_profile_temp_dir() / "playlists" / f"{safe_id}.json" # # PlaylistData # def on_data_ready(self, data, error): if isinstance(error, PlaylistFetchException): self.window.signals.playlist_fetch_error.emit(error) if data is None: data = {"entries": []} self.playlists["entries"] = data.get("entries", []) self.window.signals.playlists_ready.emit(self.playlists) def load_playlist_videos(self, playlist_id: str): cache_path = self.get_playlist_cache_path(playlist_id) if os.path.exists(cache_path): return read_json(cache_path) return None def fetch_playlist_videos(self, playlist: dict): playlist_id = playlist.get("id") playlist_url = playlist.get("url") or playlist.get("webpage_url") if not playlist_url and playlist_id and not str(playlist_id).startswith("VL"): playlist_url = f"https://www.youtube.com/playlist?list={playlist_id}" if not playlist_url: raise ValueError("Playlist URL not found.") cached_data = self.load_playlist_videos(playlist_id) if cached_data: return cached_data opts = self.get_ydl_opts() opts.update({"extract_flat": True, "skip_download": True, "quiet": True, }) with fetch_playlist_lock: with YoutubeDL(opts) as ydl: data = ydl.extract_info(playlist_url, download=False) self.save_playlist_videos(playlist_id, data) return data def save_playlist_videos(self, playlist_id: str, data: dict): save_json(data, self.get_playlist_cache_path(playlist_id)) def get_ydl_opts(self): return { 'cookiefile': self.get_profile_cookie_path(self.active_profile_name), 'verbose': True, 'download': False, 'js_runtimes': {"node": {}}, 'extract_flat': False, 'lazy_playlist': False, "ignoreerrors": False } @click.command() @click.argument("url_schema", default=None, required=False) @click.option("--cookie-file", "-cf", required=False, default=None, help="Cookies file", type=click.Path(exists=True)) @click.option("--new-work-dir", "-wd", required=False, default=None, help="New working directory", type=click.Path(exists=True)) def main(url_schema, cookie_file: str, new_work_dir: str): global work_dir icon_path = PROGRAM_DIR / "assets" / "icon.ico" if new_work_dir: work_dir = Path(new_work_dir) if work_dir is None: app = QApplication(sys.argv) if icon_path.exists(): app.setWindowIcon(QIcon(icon_path.as_posix())) folder = QFileDialog.getExistingDirectory( None, "Select a folder as the new working directory" ) if folder: work_dir = Path(folder) else: QMessageBox.warning( None, "Warning", "PlaylistSaver requires a working directory to store files. Please specify a working directory" " using argument \"--new-work-dir\".", ) sys.exit(1) app.shutdown() app = PlaylistSaver( url_schema, cookie_file, ) if icon_path.exists(): app.setWindowIcon(QIcon(icon_path.as_posix())) sys.exit(app.exec()) if __name__ == "__main__": main()