2026-06-18 21:16:09 +08:00
|
|
|
import copy
|
2026-06-15 00:16:44 +08:00
|
|
|
import datetime
|
2026-05-16 20:26:51 +08:00
|
|
|
import json
|
|
|
|
|
import logging
|
|
|
|
|
import os
|
|
|
|
|
import shutil
|
|
|
|
|
import subprocess
|
|
|
|
|
import sys
|
|
|
|
|
import threading
|
2026-06-18 21:16:09 +08:00
|
|
|
import traceback
|
|
|
|
|
import webbrowser
|
2026-06-15 00:16:44 +08:00
|
|
|
from pathlib import Path
|
2026-06-18 21:16:09 +08:00
|
|
|
from yt_dlp.networking.exceptions import HTTPError
|
|
|
|
|
from typing import Callable
|
2026-05-16 20:26:51 +08:00
|
|
|
import click
|
|
|
|
|
import requests
|
|
|
|
|
from yt_dlp import YoutubeDL
|
2026-06-18 21:16:09 +08:00
|
|
|
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
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-15 22:08:02 +08:00
|
|
|
VERSION = "Alpha-2-QtTest"
|
2026-06-18 21:16:09 +08:00
|
|
|
PROGRAM_DIR = Path(__file__).parent
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
work_dir = None
|
2026-05-16 20:26:51 +08:00
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
logger.setLevel(logging.INFO)
|
|
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
YT_PLAYLISTS_URL = "https://www.youtube.com/feed/playlists"
|
2026-05-16 20:26:51 +08:00
|
|
|
|
|
|
|
|
def sanitize_filename(value: str):
|
|
|
|
|
invalid_chars = '<>:"/\\|?*'
|
|
|
|
|
return "".join("_" if char in invalid_chars else char for char in value)
|
|
|
|
|
|
|
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
def fetch_video_details(video_url: str, opts):
|
|
|
|
|
opts = copy.deepcopy(opts)
|
|
|
|
|
opts.update({"extract_flat": True, })
|
2026-06-15 00:16:44 +08:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
|
2026-05-16 20:26:51 +08:00
|
|
|
def resolve_video_page_url(video: dict):
|
|
|
|
|
return (
|
2026-06-18 21:16:09 +08:00
|
|
|
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)
|
2026-05-16 20:26:51 +08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
def download_file(url, dest: Path):
|
2026-05-16 20:26:51 +08:00
|
|
|
try:
|
2026-06-18 21:16:09 +08:00
|
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
2026-05-16 20:26:51 +08:00
|
|
|
with requests.get(url, stream=True) as r:
|
2026-06-18 21:16:09 +08:00
|
|
|
with dest.open(mode="wb") as f:
|
2026-05-16 20:26:51 +08:00
|
|
|
for chunk in r.iter_content(chunk_size=8192):
|
|
|
|
|
f.write(chunk)
|
|
|
|
|
return True
|
|
|
|
|
except Exception as e:
|
2026-06-18 21:16:09 +08:00
|
|
|
logger.error("Unable to download file: %s", e)
|
2026-05-16 20:26:51 +08:00
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
fetch_playlist_lock = threading.Lock()
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
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,
|
|
|
|
|
}
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
def create_messagebox(self, master):
|
|
|
|
|
box = QMessageBox(master)
|
|
|
|
|
box.setWindowTitle(self.title)
|
|
|
|
|
box.setText(self.reason or self.title)
|
|
|
|
|
box.setIcon(QMessageBox.Icon.Critical)
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
is_custom_option_enabled = self.qt_options["enable"] is True and type(self.qt_options["options"]) is list
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
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)
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
# 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
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
if not isinstance(role, QMessageBox.ButtonRole):
|
|
|
|
|
logger.warning(f"Skipping {option} ({role}) because its role type is not QMessageBox.ButtonRole.")
|
|
|
|
|
continue
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
btn = box.addButton(label, role)
|
|
|
|
|
option["button"] = btn
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
if not self.qt_options["noCancelOption"]:
|
|
|
|
|
box.addButton("Cancel", QMessageBox.ButtonRole.RejectRole)
|
|
|
|
|
elif not is_custom_option_enabled:
|
|
|
|
|
box.addButton(QMessageBox.StandardButton.Ok)
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
box.exec()
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
clicked = box.clickedButton()
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
# 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")
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
if clicked == btn:
|
|
|
|
|
return action_func()
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
return None
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
def fetch_playlists_data(url_schema, cookie_file_path: Path, opts, callback: Callable):
|
|
|
|
|
check_url_scheme(os.path.abspath(__file__), work_dir)
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
error = None
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
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...")
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
playlists_data = None
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
with fetch_playlist_lock:
|
|
|
|
|
opts = copy.deepcopy(opts)
|
|
|
|
|
opts.update({
|
|
|
|
|
'extract_flat': True,
|
|
|
|
|
'skip_download': True,
|
|
|
|
|
})
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
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)},
|
|
|
|
|
],
|
2026-05-16 20:26:51 +08:00
|
|
|
)
|
|
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
callback(playlists_data, pf_exec)
|
2026-05-16 20:26:51 +08:00
|
|
|
|
|
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
class PlaylistSaver(QApplication):
|
|
|
|
|
DEFAULT_PROFILE_NAME = "default"
|
|
|
|
|
THUMBNAIL_SIZE = QSize(144, 81)
|
|
|
|
|
mpv_processes = {}
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
def __init__(self, url_schema=None, cookie_file: str | None = None):
|
|
|
|
|
super().__init__()
|
|
|
|
|
self.setApplicationName("PlaylistSaver")
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
self.url_schema = url_schema
|
|
|
|
|
self.cookie_file = cookie_file
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
# 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")
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
# Profile
|
|
|
|
|
self.active_profile_name = self.DEFAULT_PROFILE_NAME
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
# Data
|
|
|
|
|
self.playlists: dict[str, list] = {
|
|
|
|
|
"entries": []
|
|
|
|
|
}
|
2026-06-15 01:16:26 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
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,
|
|
|
|
|
)
|
2026-06-15 01:16:26 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
if len(playlist.get("thumbnails", [])) > 0:
|
|
|
|
|
thumbnail_url = playlist.get("thumbnails", [])[0].get('url', None)
|
|
|
|
|
thumbnail_resolution = playlist.get("thumbnails", [])[0].get('resolution', None)
|
2026-06-15 01:16:26 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
thumbnail_path = Path(work_dir, "temp", "thumbnails",
|
|
|
|
|
f"thumbnail_{plist_id}_{thumbnail_resolution}.jpg")
|
2026-06-15 01:16:26 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
result = download_file(thumbnail_url, thumbnail_path)
|
2026-06-15 01:16:26 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
if result or (thumbnail_path.exists() and thumbnail_path.is_file()):
|
|
|
|
|
item.set_thumbnail(thumbnail_path)
|
2026-06-15 01:16:26 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
item.clicked.connect(self.open_playlist_window)
|
|
|
|
|
self.playlist_items_layout.addWidget(item)
|
2026-06-15 01:16:26 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
self.loading_label.setText("Done")
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
@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()
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
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()
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
def show_error(self, message: str):
|
|
|
|
|
QMessageBox.critical(self, "Error", message)
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
def show_playlist_fetch_error(self, error: PlaylistFetchException):
|
|
|
|
|
error.create_messagebox(self)
|
2026-06-15 01:16:26 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
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.")
|
2026-06-15 01:16:26 +08:00
|
|
|
return
|
|
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
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()
|
2026-06-15 01:16:26 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
def mousePressEvent(self, event):
|
|
|
|
|
if event.button() == Qt.MouseButton.LeftButton:
|
|
|
|
|
self.clicked.emit(self.data)
|
|
|
|
|
super().mousePressEvent(event)
|
2026-06-15 01:16:26 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
class VideoItem(QFrame):
|
|
|
|
|
clicked = Signal(dict)
|
2026-06-15 01:16:26 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
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)
|
2026-06-15 01:16:26 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
title = video.get("title", "Unnamed Video")
|
|
|
|
|
if index:
|
|
|
|
|
title = f"{index}. {title}"
|
2026-06-15 01:16:26 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
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,
|
|
|
|
|
)
|
2026-06-15 01:16:26 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
if reply != QMessageBox.Yes:
|
|
|
|
|
return
|
2026-06-15 01:16:26 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
meta["profiles"] = [
|
|
|
|
|
name for name in meta["profiles"]
|
|
|
|
|
if name != profile_name
|
|
|
|
|
]
|
2026-06-15 01:16:26 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
self.app.save_profiles_meta(meta)
|
|
|
|
|
self.render_profiles()
|
2026-06-15 01:16:26 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
def create_profile(self):
|
|
|
|
|
profile_name, ok = QInputDialog.getText(self, "Create Profile", "Profile name:")
|
2026-06-15 01:16:26 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
if not ok:
|
|
|
|
|
return
|
2026-06-15 01:16:26 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
profile_name = profile_name.strip()
|
|
|
|
|
if not profile_name:
|
|
|
|
|
QMessageBox.critical(self, "Error", "Profile name cannot be empty.")
|
|
|
|
|
return
|
2026-06-15 01:16:26 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
self.app.ensure_profile_exists(profile_name)
|
|
|
|
|
self.render_profiles()
|
2026-06-15 01:16:26 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
def init(self):
|
|
|
|
|
self.initialize_profiles()
|
|
|
|
|
# Start background thread after root is ready for UI callbacks.
|
|
|
|
|
self.reload_playlists()
|
2026-06-15 01:16:26 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
def reload_playlists(self):
|
|
|
|
|
self.window.loading_label.setText("Loading playlists...")
|
2026-06-15 01:16:26 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
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()
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
#
|
|
|
|
|
# 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],
|
|
|
|
|
}
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
profiles = meta.get("profiles") or [self.DEFAULT_PROFILE_NAME]
|
|
|
|
|
active_profile = meta.get("active_profile") or profiles[0]
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
if active_profile not in profiles:
|
|
|
|
|
profiles.insert(0, active_profile)
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
return {
|
|
|
|
|
"active_profile": active_profile,
|
|
|
|
|
"profiles": profiles,
|
|
|
|
|
}
|
2026-06-14 22:32:25 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
def set_active_profile(self, profile_name: str):
|
|
|
|
|
meta = self.load_profiles_meta()
|
|
|
|
|
profiles = meta["profiles"]
|
2026-06-15 00:16:44 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
if profile_name not in profiles:
|
|
|
|
|
profiles.append(profile_name)
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
meta["profiles"] = profiles
|
|
|
|
|
meta["active_profile"] = profile_name
|
|
|
|
|
self.save_profiles_meta(meta)
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
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)
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
def save_profiles_meta(self, meta: dict):
|
|
|
|
|
save_json(meta, self.profiles_meta_path)
|
2026-06-15 00:16:44 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
def delete_profile(self, profile_name: str):
|
|
|
|
|
meta = self.load_profiles_meta()
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
if not profile_name in meta["profiles"]:
|
|
|
|
|
return
|
2026-06-15 00:16:44 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
meta["profiles"].remove(profile_name)
|
2026-06-15 00:16:44 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
self.save_profiles_meta(meta)
|
2026-06-15 00:16:44 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
#
|
|
|
|
|
# Path
|
|
|
|
|
#
|
|
|
|
|
def get_profile_cookie_path(self, profile_name: str):
|
|
|
|
|
return Path(self.get_profile_dir(profile_name), "cookies.txt")
|
2026-06-15 00:16:44 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
def get_profile_dir(self, profile_name: str):
|
|
|
|
|
return Path(self.profiles_path, sanitize_filename(profile_name))
|
2026-06-15 00:16:44 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
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)
|
2026-06-15 00:16:44 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
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"
|
2026-06-15 00:16:44 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
#
|
|
|
|
|
# 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": []}
|
2026-06-15 00:16:44 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
self.playlists["entries"] = data.get("entries", [])
|
|
|
|
|
self.window.signals.playlists_ready.emit(self.playlists)
|
2026-06-15 00:16:44 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
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
|
2026-06-15 00:16:44 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
def fetch_playlist_videos(self, playlist: dict):
|
|
|
|
|
playlist_id = playlist.get("id")
|
|
|
|
|
playlist_url = playlist.get("url") or playlist.get("webpage_url")
|
2026-06-15 00:16:44 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
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}"
|
2026-06-15 00:16:44 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
if not playlist_url:
|
|
|
|
|
raise ValueError("Playlist URL not found.")
|
2026-06-15 00:16:44 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
cached_data = self.load_playlist_videos(playlist_id)
|
|
|
|
|
if cached_data:
|
|
|
|
|
return cached_data
|
2026-06-15 00:16:44 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
opts = self.get_ydl_opts()
|
2026-06-15 00:16:44 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
opts.update({"extract_flat": True,
|
|
|
|
|
"skip_download": True,
|
|
|
|
|
"quiet": True, })
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
with fetch_playlist_lock:
|
|
|
|
|
with YoutubeDL(opts) as ydl:
|
|
|
|
|
data = ydl.extract_info(playlist_url, download=False)
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
self.save_playlist_videos(playlist_id, data)
|
|
|
|
|
return data
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
def save_playlist_videos(self, playlist_id: str, data: dict):
|
|
|
|
|
save_json(data, self.get_playlist_cache_path(playlist_id))
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
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
|
|
|
|
|
}
|
2026-05-16 20:26:51 +08:00
|
|
|
|
|
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
@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
|
2026-06-14 22:32:25 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
icon_path = PROGRAM_DIR / "assets" / "icon.ico"
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
if new_work_dir:
|
|
|
|
|
work_dir = Path(new_work_dir)
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
if work_dir is None:
|
|
|
|
|
app = QApplication(sys.argv)
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
if icon_path.exists():
|
|
|
|
|
app.setWindowIcon(QIcon(icon_path.as_posix()))
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
folder = QFileDialog.getExistingDirectory(
|
|
|
|
|
None, "Select a folder as the new working directory"
|
|
|
|
|
)
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
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)
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
app.shutdown()
|
2026-05-16 20:26:51 +08:00
|
|
|
|
2026-06-18 21:16:09 +08:00
|
|
|
app = PlaylistSaver(
|
|
|
|
|
url_schema,
|
|
|
|
|
cookie_file,
|
|
|
|
|
)
|
|
|
|
|
if icon_path.exists():
|
|
|
|
|
app.setWindowIcon(QIcon(icon_path.as_posix()))
|
|
|
|
|
sys.exit(app.exec())
|
2026-05-16 20:26:51 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|