Add download video and save playlist support.

This commit is contained in:
wei
2026-08-25 17:46:48 +08:00
parent eadc5c6267
commit 04e03c132d
19 changed files with 2514 additions and 1386 deletions
+6
View File
@@ -2,3 +2,9 @@
/temp/
/main.build/
/main.dist/
/build/
/dist/
/*.egg-info/
/.idea/
__pycache__/
*.py[cod]
+87
View File
@@ -0,0 +1,87 @@
from pathlib import Path
import platform
project_dir = Path(SPEC).resolve().parent
def data_tree(source: Path, destination: str):
"""Return PyInstaller data tuples while retaining the directory tree."""
return [
(str(path), str(Path(destination) / path.relative_to(source).parent))
for path in source.rglob("*")
if path.is_file()
]
def bundled_mpv_dir() -> Path:
system = platform.system().lower()
machine = platform.machine().lower()
if system == "windows":
os_dir = "nt"
elif system in {"darwin", "linux"}:
os_dir = system
else:
raise SystemExit(f"Unsupported build platform: {platform.system()}")
if machine in {"amd64", "x86_64"}:
arch_dir = "x64"
elif machine in {"arm64", "aarch64"}:
arch_dir = "arm"
elif machine in {"x86", "i386", "i686"}:
arch_dir = "x86"
else:
raise SystemExit(f"Unsupported build architecture: {platform.machine()}")
return project_dir / "bin" / "mpv" / os_dir / arch_dir
mpv_dir = bundled_mpv_dir()
if not mpv_dir.is_dir():
raise SystemExit(f"Bundled mpv directory does not exist: {mpv_dir}")
datas = data_tree(project_dir / "assets", "assets")
datas += data_tree(
mpv_dir,
str(Path("bin") / "mpv" / mpv_dir.parent.name / mpv_dir.name),
)
a = Analysis(
[str(project_dir / "main.py")],
pathex=[str(project_dir)],
binaries=[],
datas=datas,
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=["customtkinter", "PIL"],
noarchive=False,
optimize=1,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name="PlaylistSaver",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False,
console=False,
icon=str(project_dir / "assets" / "icon.ico"),
)
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=False,
name="PlaylistSaver",
)
+124 -31
View File
@@ -1,34 +1,127 @@
function bytesToBase64Url(bytes) {
let binary = "";
for (let offset = 0; offset < bytes.length; offset += 8192) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + 8192));
}
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
}
function base64UrlToBytes(value) {
const padded = value.replaceAll("-", "+").replaceAll("_", "/")
+ "=".repeat((4 - value.length % 4) % 4);
return Uint8Array.from(atob(padded), character => character.charCodeAt(0));
}
async function hmacSha256(keyBytes, data) {
const key = await crypto.subtle.importKey(
"raw", keyBytes, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]
);
return new Uint8Array(await crypto.subtle.sign("HMAC", key, data));
}
function joinBytes(...arrays) {
const result = new Uint8Array(arrays.reduce((size, array) => size + array.length, 0));
let offset = 0;
for (const array of arrays) {
result.set(array, offset);
offset += array.length;
}
return result;
}
async function encryptCookies(plaintext, secretKey) {
const masterKey = base64UrlToBytes(secretKey);
if (masterKey.length !== 32) {
throw new Error("The Helper secret key is invalid.");
}
const encoder = new TextEncoder();
const encryptionKey = await hmacSha256(masterKey, encoder.encode("PlaylistSaver cookie encryption"));
const authenticationKey = await hmacSha256(masterKey, encoder.encode("PlaylistSaver cookie authentication"));
const nonce = crypto.getRandomValues(new Uint8Array(16));
const plaintextBytes = encoder.encode(plaintext);
const ciphertext = new Uint8Array(plaintextBytes.length);
for (let offset = 0; offset < plaintextBytes.length; offset += 32) {
const counter = new Uint8Array(4);
new DataView(counter.buffer).setUint32(0, offset / 32, false);
const stream = await hmacSha256(encryptionKey, joinBytes(nonce, counter));
const blockLength = Math.min(32, plaintextBytes.length - offset);
for (let index = 0; index < blockLength; index++) {
ciphertext[offset + index] = plaintextBytes[offset + index] ^ stream[index];
}
}
const signedData = joinBytes(Uint8Array.of(1), nonce, ciphertext);
const tag = await hmacSha256(authenticationKey, signedData);
return bytesToBase64Url(joinBytes(signedData, tag));
}
async function launchPlaylistSaver() {
const { secretKey } = await chrome.storage.local.get("secretKey");
if (!secretKey) {
await chrome.storage.local.set({ showMissingKeyAlert: true });
await chrome.runtime.openOptionsPage();
return;
}
const cookies = await chrome.cookies.getAll({ domain: ".youtube.com" });
let output = "# Netscape HTTP Cookie File\n";
for (const cookie of cookies) {
const rawDomain = cookie.domain;
const domain = cookie.httpOnly ? `#HttpOnly_${rawDomain}` : rawDomain;
output += [
domain,
rawDomain.startsWith(".") ? "TRUE" : "FALSE",
cookie.path,
cookie.secure ? "TRUE" : "FALSE",
cookie.expirationDate ? Math.floor(cookie.expirationDate) : 0,
cookie.name,
cookie.value
].join("\t") + "\n";
}
const encrypted = await encryptCookies(output, secretKey.trim());
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tabs[0]?.id) {
throw new Error("No active tab is available to launch PlaylistSaver.");
}
await chrome.tabs.update(tabs[0].id, {
url: `playlistsaver://open?encryptedcookies=${encrypted}`
});
}
async function openHelperSettings() {
await chrome.runtime.openOptionsPage();
}
chrome.commands.onCommand.addListener((command) => {
if (command === "openPLKey") {
chrome.cookies.getAll({ domain: ".youtube.com" }, (cookies) => {
let output = "# Netscape HTTP Cookie File\n";
cookies.forEach(c => {
const domain = c.domain;
const flag = domain.startsWith('.') ? "TRUE" : "FALSE";
const path = c.path;
const secure = c.secure ? "TRUE" : "FALSE";
const expiration = c.expirationDate ? Math.floor(c.expirationDate) : 0;
output += [
domain,
flag,
path,
secure,
expiration,
c.name,
c.value
].join("\t") + "\n";
});
const encoded = btoa(output);
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
chrome.tabs.update(tabs[0].id, {
url: `PlaylistSaver://open?base64cookies=${encoded}`
});
});
});
launchPlaylistSaver().catch(error => console.error("Unable to launch PlaylistSaver:", error));
} else if (command === "openPLSettingPageKey") {
openHelperSettings().catch(error => console.error("Unable to open Helper settings:", error));
}
});
});
chrome.runtime.onInstalled.addListener(() => {
chrome.contextMenus.removeAll(() => {
chrome.contextMenus.create({
id: "openPlaylistSaver",
title: "Open PlaylistSaver",
contexts: ["page"]
});
chrome.contextMenus.create({
id: "openHelperSettings",
title: "PlaylistSaver Helper Settings",
contexts: ["page"]
});
});
});
chrome.contextMenus.onClicked.addListener((info) => {
if (info.menuItemId === "openPlaylistSaver") {
launchPlaylistSaver().catch(error => console.error("Unable to launch PlaylistSaver:", error));
} else if (info.menuItemId === "openHelperSettings") {
openHelperSettings().catch(error => console.error("Unable to open Helper settings:", error));
}
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

+15 -4
View File
@@ -4,9 +4,10 @@
"version": "1.0",
"permissions": [
"scripting",
"activeTab",
"cookies"
"contextMenus",
"cookies",
"storage"
],
"host_permissions": [
"*://*.youtube.com/*"
@@ -16,12 +17,22 @@
"service_worker": "background.js"
},
"options_page": "options.html",
"icons": {
"128": "icon.png"
},
"commands": {
"openPLKey": {
"openPLSettingPageKey": {
"suggested_key": {
"default": "Alt+Z"
},
"description": "Open settings page"
},
"openPLKey": {
"suggested_key": {
"default": "Alt+Y"
},
"description": "Launch PlaylistSaver"
}
}
}
}
+28 -1
View File
@@ -1,3 +1,30 @@
# PlaylistSaver
A tool to save your youtube playlist. **VERY UNSTABLE**
A tool to save your youtube playlist. **VERY UNSTABLE**
## Development
Create a virtual environment and install the project with its build tools:
```powershell
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install -e ".[build]"
```
Run from source:
```powershell
.\.venv\Scripts\playlist-saver.exe
```
## Build
Build the application with PyInstaller:
```powershell
.\build.ps1 -Clean
```
The application is written to `dist\PlaylistSaver`. The build includes only the
bundled mpv files matching the operating system and CPU architecture on which the
build is performed. Build each target platform on that platform.
+390
View File
@@ -0,0 +1,390 @@
# Qt
import logging
import os
import secrets
import threading
import webbrowser
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from PySide6.QtWidgets import QApplication, QMessageBox
from PySide6.QtCore import QSize
from yt_dlp import YoutubeDL
from constant import PROGRAM_DIR
from window import PlaylistMainWindow
from yt_lib import sanitize_filename, fetch_playlists_data, PlaylistFetchException, resolve_video_page_url
from utils import save_json, read_json
from pl_lib import messagebox
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
class PlaylistSaver(QApplication):
DEFAULT_PROFILE_NAME = "default"
THUMBNAIL_SIZE = QSize(144, 81)
mpv_processes = {}
def __init__(self, work_dir: Path, url_schema=None, cookie_file: str | Path | None = None):
super().__init__()
self.setApplicationName("PlaylistSaver")
self.url_schema = url_schema
self.cookie_file = Path(cookie_file) if cookie_file else None
# Path
self.work_dir = work_dir
self.save_path = Path( self.work_dir, "save")
self.temp_path = Path( self.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")
self.secret_key_path = Path(self.save_path, "helper-secret.key")
self.secret_key = self.load_or_create_secret_key()
# Profile
self.active_profile_name = self.DEFAULT_PROFILE_NAME
# Data
self.playlists: dict[str, list] = {
"entries": []
}
self.main_window = PlaylistMainWindow(
self
)
# Lock
self.fetch_playlist_lock = threading.Lock()
self.download_pool = ThreadPoolExecutor(max_workers=4, thread_name_prefix="video-download")
self.download_tasks = set()
self.download_tasks_lock = threading.Lock()
self.download_cancel_event = threading.Event()
self.aboutToQuit.connect(lambda: self.download_pool.shutdown(wait=False, cancel_futures=False))
# flags
self.use_custom_cookie_file_path = self.cookie_file is not None
if self.use_custom_cookie_file_path:
cookie_path = self.cookie_file
if not cookie_path.exists() or not cookie_path.is_file():
self.main_window.signals.error.emit(
"Cookie file does not exist or is not a file."
)
else:
self.cookie_path = cookie_path
self.init()
self.main_window.show()
def init(self):
self.initialize_profiles()
# Start background thread after root is ready for UI callbacks.
self.reload_playlists()
def reload_playlists(self):
self.main_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,
self.work_dir,
self.fetch_playlist_lock,
PROGRAM_DIR / "main.py",
self.secret_key,
),
daemon=True,
).start()
#
# Profile
#
def load_or_create_secret_key(self):
try:
if self.secret_key_path.exists():
key = self.secret_key_path.read_text(encoding="utf-8").strip()
if key:
return key
self.secret_key_path.parent.mkdir(parents=True, exist_ok=True)
key = secrets.token_urlsafe(32)
self.secret_key_path.write_text(key, encoding="utf-8")
return key
except OSError as e:
logger.error("Unable to load or create Helper secret key: %s", e)
return None
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.main_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):
if self.use_custom_cookie_file_path:
return self.cookie_file
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 / "profiles" / 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.main_window.signals.playlist_fetch_error.emit(error)
if data is None:
data = {"entries": []}
self.playlists["entries"] = data.get("entries", [])
self.main_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 self.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
}
def get_download_dir(self, playlist_name: str | None = None):
directory = self.temp_path / "downloads"
if playlist_name:
safe_playlist_name = sanitize_filename(playlist_name).strip(" .") or "Unnamed Playlist"
directory = directory / "playlists" / safe_playlist_name
directory.mkdir(parents=True, exist_ok=True)
return directory
def _download_video_worker(self, video: dict, playlist_name: str | None = None):
video_id = str(video.get("id") or "unknown")
video_url = resolve_video_page_url(video, video_id)
video_name = sanitize_filename(str(video.get("title") or "Unnamed Video")).strip(" .") or "Unnamed Video"
output_base = self.get_download_dir(playlist_name) / f"{video_name}-{sanitize_filename(video_id)}"
opts = self.get_ydl_opts()
opts.update({
"download": True,
"skip_download": False,
"quiet": True,
"noplaylist": True,
"format": "bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]/bv*+ba/b",
"merge_output_format": "mp4",
"outtmpl": f"{output_base}.%(ext)s",
"postprocessors": [{"key": "FFmpegVideoRemuxer", "preferedformat": "mp4"}],
"progress_hooks": [self._check_download_cancelled],
})
with YoutubeDL(opts) as ydl:
ydl.download([video_url])
return Path(f"{output_base}.mp4")
def _check_download_cancelled(self, _status):
if self.download_cancel_event.is_set():
raise RuntimeError("Download cancelled because the application is closing.")
def _submit_download_task(self, function, *args):
future = self.download_pool.submit(function, *args)
with self.download_tasks_lock:
self.download_tasks.add(future)
future.add_done_callback(self._forget_download_task)
return future
def _forget_download_task(self, future):
with self.download_tasks_lock:
self.download_tasks.discard(future)
def has_active_download_tasks(self):
with self.download_tasks_lock:
return any(not future.done() for future in self.download_tasks)
def cancel_download_tasks(self):
self.download_cancel_event.set()
with self.download_tasks_lock:
for future in self.download_tasks:
future.cancel()
def download_video(self, video: dict, playlist_name: str | None = None,
callback=None, reset_cancel=True):
if reset_cancel:
self.download_cancel_event.clear()
future = self._submit_download_task(self._download_video_worker, video, playlist_name)
if callback:
future.add_done_callback(
lambda result: callback(result, video, playlist_name)
)
return future
def download_playlist(self, playlist: dict, playlist_data: dict | None = None,
callback=None, queued_callback=None, producer_callback=None,
reset_cancel=True):
def queue_videos():
try:
data = playlist_data or self.fetch_playlist_videos(playlist)
entries = [entry for entry in data.get("entries", []) if entry]
if self.download_cancel_event.is_set():
return
if queued_callback:
queued_callback(len(entries))
title = str(playlist.get("title") or playlist.get("id") or "Unnamed Playlist")
for video in entries:
if self.download_cancel_event.is_set():
break
self.download_video(video, title, callback, reset_cancel=False)
except Exception as error:
if queued_callback:
queued_callback(1)
if callback:
callback(error, None, playlist.get("title"))
finally:
if producer_callback:
producer_callback()
if reset_cancel:
self.download_cancel_event.clear()
return self._submit_download_task(queue_videos)
def download_all_playlists(self, callback=None, queued_callback=None,
producer_callback=None):
self.download_cancel_event.clear()
for playlist in (item for item in self.playlists.get("entries", []) if item):
self.download_playlist(
playlist, callback=callback, queued_callback=queued_callback,
producer_callback=producer_callback, reset_cancel=False
)
def open_download_folder(self):
download_dir = self.temp_path / "downloads"
if not download_dir.is_dir():
messagebox.error(self.main_window, title="Error", message="The download folder does not exist.")
return
webbrowser.open(download_dir.as_posix())
+40
View File
@@ -0,0 +1,40 @@
[CmdletBinding()]
param(
[switch]$Clean
)
$ErrorActionPreference = "Stop"
$ProjectDir = $PSScriptRoot
$VenvPython = Join-Path $ProjectDir ".venv\Scripts\python.exe"
$Python = if (Test-Path -LiteralPath $VenvPython) { $VenvPython } else { "python" }
if ($Clean) {
$BuildDir = Join-Path $ProjectDir "build"
$DistDir = Join-Path $ProjectDir "dist"
if (Test-Path -LiteralPath $BuildDir) {
Remove-Item -LiteralPath $BuildDir -Recurse -Force
}
if (Test-Path -LiteralPath $DistDir) {
Remove-Item -LiteralPath $DistDir -Recurse -Force
}
}
& $Python -c "import PyInstaller" 2>$null
if ($LASTEXITCODE -ne 0) {
throw 'PyInstaller is not installed. Run: .\.venv\Scripts\python.exe -m pip install -e ".[build]"'
}
Push-Location $ProjectDir
try {
& $Python -m PyInstaller --noconfirm "PlaylistSaver.spec"
if ($LASTEXITCODE -ne 0) {
throw "PyInstaller exited with code $LASTEXITCODE."
}
}
finally {
Pop-Location
}
Write-Host "Build completed: $ProjectDir\dist\PlaylistSaver"
+5
View File
@@ -0,0 +1,5 @@
from pathlib import Path
VERSION = "0.0.3"
YT_PLAYLISTS_URL = "https://www.youtube.com/feed/playlists"
PROGRAM_DIR = Path(__file__).parent
+1 -1
View File
@@ -1006,7 +1006,7 @@ def main(url_schema, cookie_file: str):
thumbnail_path = Path(
ROOT_DIR,
"temp",
"../temp",
"thumbnails",
f"thumbnail_{video.id}_{thumbnail_resolution}.jpg"
)
+615
View File
@@ -0,0 +1,615 @@
import logging
import subprocess
import threading
from pathlib import Path
from PySide6.QtCore import Signal, QObject, QTimer
from PySide6.QtWidgets import (
QDialog,
QLabel, QPushButton, QProgressDialog,
QVBoxLayout, QFrame, QHBoxLayout, QMessageBox, QInputDialog, QScrollArea
)
from constant import PROGRAM_DIR
from pl_lib.messagebox import ask_yes_no_with_plain_text
from utils import find_mpv_path, download_file
from widgets import VideoItem
from yt_lib import resolve_video_page_url
logger = logging.getLogger("PlayerDialog")
logger.setLevel(logging.INFO)
class DownloadProgressDialog(QProgressDialog):
tasks_added = Signal(int)
task_finished = Signal(str, bool)
producer_finished = Signal()
def __init__(self, title: str, total: int, parent=None, producers: int = 0):
super().__init__("Preparing downloads...", "Hide", 0, total, parent)
self.completed_count = 0
self.failed_count = 0
self.failed_videos = []
self.remaining_producers = producers
self.summary_shown = False
self.setWindowTitle(title)
self.setFixedWidth(540)
self.setMinimumDuration(0)
self.setAutoClose(False)
self.setAutoReset(False)
if total == 0:
self.setRange(0, 0)
else:
self.setValue(0)
self.tasks_added.connect(self.add_tasks)
self.task_finished.connect(self.update_task)
self.producer_finished.connect(self.finish_producer)
status_label = self.findChild(QLabel)
if status_label is not None:
status_label.setWordWrap(True)
status_label.setMaximumWidth(500)
self.show()
def add_tasks(self, count: int):
if self.maximum() == 0:
self.setRange(0, count)
else:
self.setMaximum(self.maximum() + count)
self.setValue(self.completed_count)
self.setLabelText(f"Downloaded {self.completed_count}/{self.maximum()} videos")
def finish_future(self, result, video=None, playlist_name=None):
video_title = (video or {}).get("title") or "Unknown video"
video_id = (video or {}).get("id") or "unknown"
display_name = f"{video_title} [{video_id}]"
if playlist_name:
display_name = f"{playlist_name} / {display_name}"
if isinstance(result, Exception):
self.task_finished.emit(f"{display_name}\n{result}", False)
return
try:
path = result.result()
except Exception as error:
self.task_finished.emit(f"{display_name}\n{error}", False)
return
self.task_finished.emit(path.name, True)
def update_task(self, message: str, succeeded: bool):
self.completed_count += 1
if not succeeded:
self.failed_count += 1
self.failed_videos.append(message)
self.setValue(self.completed_count)
status = f"Downloaded {self.completed_count}/{self.maximum()} videos"
if self.failed_count:
status += f" ({self.failed_count} failed)"
self.setLabelText(f"{status}\n{message}")
self.show_failure_summary_if_finished()
def finish_producer(self):
self.remaining_producers = max(0, self.remaining_producers - 1)
self.show_failure_summary_if_finished()
def show_failure_summary_if_finished(self):
if self.summary_shown or self.remaining_producers > 0:
return
if self.maximum() == 0 or self.completed_count < self.maximum():
return
self.summary_shown = True
if not self.failed_videos:
return
def show_summary():
should_close = ask_yes_no_with_plain_text(
self.parentWidget(),
"Download failures",
f"{len(self.failed_videos)} video(s) could not be downloaded. "
"Close the download progress window?",
"\n\n".join(self.failed_videos),
)
if should_close:
self.hide()
QTimer.singleShot(0, show_summary)
class PlayerDialog(QDialog):
status_changed = Signal(str)
def __init__(self, app, video: dict, /, parent):
QDialog.__init__(self, parent)
self.app = app
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.app.mpv_processes[self] = self.process
self.status_changed.emit("Playing in mpv...")
self.process.wait()
self.app.mpv_processes.pop(self, None)
self.status_changed.emit("Playback finished.")
def closeEvent(self, event):
process = self.app.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, app, /, parent):
QDialog.__init__(self, parent)
self.app = app
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.Shape.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;
}
""")
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.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.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()
class PlaylistWindow(QDialog):
class LoadSignals(QObject):
playlist_ready = Signal(dict)
error = Signal(str)
download_status = Signal(str)
def __init__(self, app, playlist: dict, /, parent=None,
open_player_callback=None):
QDialog.__init__(self, parent)
self.app = app
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)
self.open_player_callback = open_player_callback
# 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.signals.download_status.connect(self.info_label.setText)
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.Shape.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.save_playlist_btn = QPushButton("Save Playlist")
self.reload_playlist_btn.setMinimumHeight(34)
self.reload_playlist_btn.clicked.connect(self.render_playlist)
self.save_playlist_btn.clicked.connect(self.save_playlist)
self.control_layout.addWidget(self.reload_playlist_btn)
self.control_layout.addWidget(self.save_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;
}
""")
def render_playlist(self):
self.reload_playlist_btn.setEnabled(False)
self.info_label.setText("Loading playlist videos...")
self.app.main_window.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):
self.playlist_data = playlist_data
self.app.main_window.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 = VideoItem(
self.list_frame, video, index, thumbnail_path,
thumbnail_size=self.app.THUMBNAIL_SIZE,
download_video_callback=lambda _checked=False, current=video: self.download_video(current),
)
item.clicked.connect(self.open_player_callback)
self.list_layout.addWidget(item)
self.list_layout.addStretch(1)
def save_playlist(self):
data = getattr(self, "playlist_data", None)
total = len([entry for entry in data.get("entries", []) if entry]) if data else 0
self.progress_dialog = DownloadProgressDialog(
"Save Playlist", total, self.app.main_window, producers=1
)
queued_callback = None if data else self.progress_dialog.tasks_added.emit
self.app.download_playlist(
self.playlist, data, self.progress_dialog.finish_future, queued_callback,
self.progress_dialog.producer_finished.emit,
)
def download_video(self, video: dict):
self.progress_dialog = DownloadProgressDialog("Download Video", 1, self.app.main_window)
self.app.download_video(video, callback=self.progress_dialog.finish_future)
def on_download_done(self, result, video=None, playlist_name=None):
if isinstance(result, Exception):
self.signals.error.emit(f"Download failed: {result}")
return
try:
path = result.result()
except Exception as error:
self.signals.error.emit(f"Download failed: {error}")
return
self.signals.download_status.emit(f"Downloaded: {path.name}")
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(self.app.temp_path, "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
+34 -1324
View File
File diff suppressed because it is too large Load Diff
+108
View File
@@ -0,0 +1,108 @@
from PySide6.QtWidgets import QMessageBox, QMainWindow, QWidget, QDialog, QVBoxLayout, QLabel, QPlainTextEdit, \
QDialogButtonBox, QSizePolicy
def create_messagebox(parent: QWidget | None, title: str, message: str,
icon: QMessageBox.Icon = QMessageBox.Icon.Information,
button: QMessageBox.StandardButton = QMessageBox.StandardButton.Ok) -> QMessageBox:
msg = QMessageBox(parent)
msg.setWindowTitle(title)
msg.setText(message)
msg.setIcon(icon)
msg.setStandardButtons(button)
return msg
def error(parent: QWidget | None, title: str, message: str,
button: QMessageBox.StandardButton = QMessageBox.StandardButton.Ok) -> None:
create_messagebox(
parent,
title=title,
message=message,
icon=QMessageBox.Icon.Critical,
button=button
).exec()
def info(parent: QWidget | None, title: str, message: str,
button: QMessageBox.StandardButton = QMessageBox.StandardButton.Ok) -> None:
create_messagebox(
parent,
title=title,
message=message,
icon=QMessageBox.Icon.Information,
button=button
).exec()
def warning(parent: QWidget | None, title: str, message: str,
button: QMessageBox.StandardButton = QMessageBox.StandardButton.Ok) -> None:
create_messagebox(
parent,
title=title,
message=message,
icon=QMessageBox.Icon.Warning,
button=button
).exec()
def error_with_plain_text(
parent: QWidget | None,
title: str,
message: str,
content: str,
) -> None:
dialog = QDialog(parent)
dialog.setWindowTitle(title)
dialog.resize(720, 420)
layout = QVBoxLayout(dialog)
label = QLabel(message)
label.setWordWrap(True)
details_box = QPlainTextEdit()
details_box.setReadOnly(True)
details_box.setPlainText(content)
details_box.setSizePolicy(
QSizePolicy.Policy.Expanding,
QSizePolicy.Policy.Expanding,
)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok)
buttons.accepted.connect(dialog.accept)
layout.addWidget(label)
layout.addWidget(details_box, 1)
layout.addWidget(buttons)
dialog.exec()
def ask_yes_no_with_plain_text(parent: QWidget | None, title: str, message: str, content: str):
dialog = QDialog(parent)
dialog.setWindowTitle(title)
dialog.resize(720, 420)
layout = QVBoxLayout(dialog)
label = QLabel(message)
label.setWordWrap(True)
label.setWordWrap(True)
details_box = QPlainTextEdit()
details_box.setReadOnly(True)
details_box.setPlainText(content)
details_box.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Yes |
QDialogButtonBox.StandardButton.No
)
buttons.button(QDialogButtonBox.StandardButton.Yes).clicked.connect(dialog.accept)
buttons.button(QDialogButtonBox.StandardButton.No).clicked.connect(dialog.reject)
layout.addWidget(label)
layout.addWidget(details_box, 1)
layout.addWidget(buttons)
return dialog.exec() == QDialog.DialogCode.Accepted
+171
View File
@@ -0,0 +1,171 @@
from PySide6.QtCore import Qt, Slot, QTimer
from PySide6.QtWidgets import (
QDialog,
QLabel,
QProgressBar,
QPushButton,
QScrollArea,
QSizePolicy,
QVBoxLayout,
QWidget,
)
class ProgressRow(QWidget):
def __init__(self, name: str, parent=None):
super().__init__(parent)
self.setSizePolicy(
QSizePolicy.Policy.Expanding,
QSizePolicy.Policy.Fixed,
)
self.name_label = QLabel(name, self)
self.name_label.setWordWrap(True)
self.name_label.setTextInteractionFlags(
Qt.TextInteractionFlag.TextSelectableByMouse
)
self.status_label = QLabel("Preparing...", self)
self.status_label.setWordWrap(True)
self.status_label.setTextInteractionFlags(
Qt.TextInteractionFlag.TextSelectableByMouse
)
self.progress_bar = QProgressBar(self)
self.progress_bar.setRange(0, 100)
self.progress_bar.setValue(0)
# Flags
self.finished = False
layout = QVBoxLayout(self)
layout.setContentsMargins(8, 8, 8, 8)
layout.setSpacing(4)
layout.addWidget(self.name_label)
layout.addWidget(self.status_label)
layout.addWidget(self.progress_bar)
class ProgressDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Downloads")
self.resize(500, 300)
self.setMinimumSize(360, 220)
self.rows: dict[str, ProgressRow] = {}
self.scroll_area = QScrollArea(self)
self.download_widget = QWidget(self.scroll_area)
self.download_layout = QVBoxLayout(self.download_widget)
self.download_layout.setContentsMargins(4, 4, 4, 4)
self.download_layout.setSpacing(8)
self.download_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.scroll_area.setWidget(self.download_widget)
self.scroll_area.setWidgetResizable(True)
self.scroll_area.setHorizontalScrollBarPolicy(
Qt.ScrollBarPolicy.ScrollBarAlwaysOff
)
self.close_button = QPushButton("Close", self)
self.close_button.clicked.connect(self.hide)
layout = QVBoxLayout(self)
layout.setContentsMargins(8, 8, 8, 8)
layout.setSpacing(8)
layout.addWidget(self.scroll_area, 1)
layout.addWidget(
self.close_button,
0,
Qt.AlignmentFlag.AlignRight,
)
self.close_timer = QTimer(self)
self.close_timer.setSingleShot(True)
self.close_timer.timeout.connect(self.cleanup)
@Slot(str, str)
def add_task(self, task_id: str, name: str):
# Prevent new tasks from being deleted by cleanup timer
if task_id in self.rows:
return
row = ProgressRow(name, self.download_widget)
self.rows[task_id] = row
self.download_layout.addWidget(row)
if not self.isVisible():
self.show()
@Slot(str, str, float)
def update_task(
self,
task_id: str,
status: str,
percent: float,
):
row = self.rows.get(task_id)
if row is None:
# Prevent started、progress missing due to timing issues
self.add_task(task_id, task_id)
row = self.rows[task_id]
value = max(0, min(100, round(percent)))
row.status_label.setText(status)
row.progress_bar.setValue(value)
@Slot(str)
def finish_task(self, task_id: str):
row = self.rows.get(task_id)
if row is None:
return
row.status_label.setText("Completed")
row.progress_bar.setValue(100)
row.finished = True
if self.all_tasks_finished():
self.close_timer.start(600)
def all_tasks_finished(self):
return bool(self.rows) and all(
row.finished
for row in self.rows.values()
)
def cleanup(self):
"""
If you want to use this function with a timer. use self.close_timer.start(TIMEOUT)
:return:
"""
for task_id in list(self.rows):
self.remove_task(task_id)
self.hide()
@Slot(str, str)
def fail_task(self, task_id: str, error: str):
row = self.rows.get(task_id)
if row is None:
self.add_task(task_id, task_id)
row = self.rows[task_id]
row.status_label.setText(f"Failed: {error}")
row.progress_bar.setStyleSheet(
"QProgressBar::chunk { background-color: #c62828; }"
)
# Move to top
self.download_layout.removeWidget(row)
self.download_layout.insertWidget(0, row)
@Slot(str)
def remove_task(self, task_id: str):
row = self.rows.pop(task_id, None)
if row is not None:
self.download_layout.removeWidget(row)
row.setParent(None)
row.deleteLater()
+46
View File
@@ -0,0 +1,46 @@
[build-system]
requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta"
[project]
name = "playlist-saver"
dynamic = ["version"]
description = "A desktop tool for saving YouTube playlists."
readme = "README.md"
requires-python = ">=3.10"
license = "MIT"
dependencies = [
"click>=8.1",
"platformdirs>=4.0",
"PySide6>=6.8",
"requests>=2.31",
"yt-dlp>=2025.1.15",
]
[project.optional-dependencies]
build = [
"pyinstaller>=6.11",
]
[project.scripts]
playlist-saver = "main:main"
[tool.setuptools]
py-modules = [
"app",
"constant",
"dialog",
"main",
"utils",
"widgets",
"window",
"yt_lib",
]
[tool.setuptools.packages.find]
include = ["pl_lib*"]
namespaces = true
[tool.setuptools.dynamic]
version = {attr = "constant.VERSION"}
+91 -25
View File
@@ -1,15 +1,20 @@
import base64
import binascii
import hashlib
import hmac
import json
import platform
import shutil
import urllib
import winreg
try:
import winreg
except ImportError: # Windows-only standard library module
winreg = None
from pathlib import Path
from urllib.parse import unquote
import sys
import os
import logging
import requests
logger = logging.getLogger("PlaylistSaver.Utils")
logger.setLevel(logging.INFO)
@@ -20,6 +25,9 @@ def set_info(target_logger):
logger = target_logger
def key_exists(hive, sub_key):
if winreg is None:
return False
try:
# Attempt to open the key for reading
with winreg.OpenKey(hive, sub_key, 0, winreg.KEY_READ) as key:
@@ -42,9 +50,9 @@ def create_url_scheme(main_file_path, work_dir):
command_key = winreg.CreateKey(open_key, "command")
if getattr(sys, 'frozen', False):
command = f"\"{sys.executable}\" --new-work-dir \"{work_dir}\" \"%1\""
command = f"\"{sys.executable}\" --work-dir \"{work_dir}\" \"%1\""
else:
command = f"\"{sys.executable}\" \"{main_file_path}\" --new-work-dir \"{work_dir}\" \"%1\""
command = f"\"{sys.executable}\" \"{main_file_path}\" --work-dir \"{work_dir}\" \"%1\""
winreg.SetValueEx(command_key, None, 0, winreg.REG_SZ, command)
@@ -53,12 +61,15 @@ def create_url_scheme(main_file_path, work_dir):
except OSError as e:
logger.error("Unable to create URL scheme: %s", e)
def check_url_scheme(main_file_path, work_dir):
def check_url_scheme(executable_path, work_dir):
if winreg is None:
return
try:
if not key_exists(winreg.HKEY_LOCAL_MACHINE, "PlaylistSaver"):
create_url_scheme(main_file_path, work_dir)
if not key_exists(winreg.HKEY_CURRENT_USER, r"Software\Classes\PlaylistSaver"):
create_url_scheme(executable_path, work_dir)
except FileNotFoundError:
create_url_scheme(main_file_path, work_dir)
create_url_scheme(executable_path, work_dir)
def read_cookies(cookie_path):
try:
@@ -84,19 +95,54 @@ def save_base64_netscape_cookie(cookies_encoded, cookie_path):
logger.error("Unable to decode base64 cookie: %s", cookies_encoded)
return
logger.debug("==== COOKIE FILE ====")
for line in cookies.split("\n"):
print(line, "=>", len(line.split("\t")))
logger.debug("=====================")
save_cookies(cookies, cookie_path)
def parser_url_scheme(url, cookie_path: Path):
def _decode_urlsafe(value: str) -> bytes:
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
def decrypt_cookie_payload(payload: str, secret_key: str) -> str:
raw = _decode_urlsafe(payload)
if len(raw) < 49 or raw[0] != 1:
raise ValueError("Unsupported encrypted cookie payload.")
master_key = _decode_urlsafe(secret_key)
if len(master_key) != 32:
raise ValueError("Invalid PlaylistSaver secret key.")
signed_data, supplied_tag = raw[:-32], raw[-32:]
nonce, ciphertext = raw[1:17], raw[17:-32]
encryption_key = hmac.new(master_key, b"PlaylistSaver cookie encryption", hashlib.sha256).digest()
authentication_key = hmac.new(master_key, b"PlaylistSaver cookie authentication", hashlib.sha256).digest()
expected_tag = hmac.new(authentication_key, signed_data, hashlib.sha256).digest()
if not hmac.compare_digest(supplied_tag, expected_tag):
raise ValueError("Cookie payload authentication failed. Check the Helper secret key.")
plaintext = bytearray(len(ciphertext))
for offset in range(0, len(ciphertext), 32):
counter = (offset // 32).to_bytes(4, "big")
stream = hmac.new(encryption_key, nonce + counter, hashlib.sha256).digest()
block = ciphertext[offset:offset + 32]
plaintext[offset:offset + len(block)] = bytes(a ^ b for a, b in zip(block, stream))
return plaintext.decode("utf-8")
def parser_url_scheme(url, cookie_path: Path, secret_key: str | None = None):
logger.debug("Parsing URL scheme: %s", url)
url = urllib.parse.urlparse(unquote(url))
# parse_qs performs percent-decoding itself. Decoding the whole URL first
# would turn an encoded Base64 "+" into a query-string space.
url = urllib.parse.urlparse(url)
parameters = urllib.parse.parse_qs(url.query)
if "encryptedcookies" in parameters:
if not secret_key:
raise ValueError("PlaylistSaver secret key is unavailable.")
cookies = decrypt_cookie_payload(parameters["encryptedcookies"][0], secret_key)
save_cookies(cookies, cookie_path)
return
func_map = {
"base64cookies": {
"func": save_base64_netscape_cookie,
@@ -133,27 +179,33 @@ def find_mpv_path(program_dir: Path):
platform_name = platform.system().lower()
machine = platform.machine().lower()
if platform.system().lower() == "windows" and ("amd64" in machine or "x86_64" in machine):
if platform_name == "windows" and ("amd64" in machine or "x86_64" in machine):
local_candidates.append(Path(program_dir, "bin", "mpv", "nt", "x64", "mpv.exe"))
elif platform.system().lower() == "windows" and ("arm64" in machine or "aarch64" in machine):
elif platform_name == "windows" and ("arm64" in machine or "aarch64" in machine):
local_candidates.append(Path(program_dir, "bin", "mpv", "nt", "arm", "mpv.exe"))
elif platform.system().lower() == "windows" and machine in {"x86", "i386", "i686"}:
elif platform_name == "windows" and machine in {"x86", "i386", "i686"}:
local_candidates.append(Path(program_dir, "bin", "mpv", "nt", "x86", "mpv.exe"))
if platform.system().lower() == "darwin" and ("amd64" in machine or "x86_64" in machine):
if platform_name == "darwin" and ("amd64" in machine or "x86_64" in machine):
local_candidates.append(Path(program_dir, "bin", "mpv", "darwin", "x64", "mpv"))
elif platform.system() == "darwin" and ("arm64" in machine or "aarch64" in machine):
elif platform_name == "darwin" and ("arm64" in machine or "aarch64" in machine):
local_candidates.append(Path(program_dir, "bin", "mpv", "darwin", "arm", "mpv"))
if platform.system().lower() == "linux" and ("amd64" in machine or "x86_64" in machine):
if platform_name == "linux" and ("amd64" in machine or "x86_64" in machine):
local_candidates.append(Path(program_dir, "bin", "mpv", "linux", "x64", "mpv"))
elif platform.system().lower() == "linux" and ("arm64" in machine or "aarch64" in machine):
elif platform_name == "linux" and ("arm64" in machine or "aarch64" in machine):
local_candidates.append(Path(program_dir, "bin", "mpv", "linux", "arm", "mpv"))
elif platform.system().lower() == "linux" and machine in {"x86", "i386", "i686"}:
local_candidates.append(Path(program_dir, "bin", "mpv", "linux", "x86", "mpv.exe"))
elif platform_name == "linux" and machine in {"x86", "i386", "i686"}:
local_candidates.append(Path(program_dir, "bin", "mpv", "linux", "x86", "mpv"))
for candidate in local_candidates:
if candidate.exists() and candidate.is_file():
if os.name != "nt" and not os.access(candidate, os.X_OK):
try:
candidate.chmod(candidate.stat().st_mode | 0o111)
except OSError as exc:
logger.warning("Unable to make bundled mpv executable: %s", exc)
continue
return candidate
# failback to PATH mpv (if available)
@@ -201,4 +253,18 @@ def cookie_string_to_netscape(cookie_str):
value.strip()
]))
return "\n".join(lines)
return "\n".join(lines)
def download_file(url, dest: Path):
try:
dest.parent.mkdir(parents=True, exist_ok=True)
with requests.get(url, stream=True, timeout=30) as r:
r.raise_for_status()
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
+252
View File
@@ -0,0 +1,252 @@
import datetime
from pathlib import Path
from typing import Callable
from PySide6.QtWidgets import QLabel, QFrame, QHBoxLayout, QSizePolicy, QPushButton, QVBoxLayout
from PySide6.QtCore import Qt, Signal, QSize
from PySide6.QtGui import QPixmap
from yt_lib import resolve_video_page_url
class PlaylistItem(QFrame):
clicked = Signal(dict)
def __init__(self,
parent,
data,
title: str = "",
thumbnail_path: str | Path | None = None,
thumbnail_size: QSize = 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(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
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,
thumbnail_size: QSize = None,
download_video_callback: Callable = 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 Video", 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(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.AlignLeft)
self.layout.addLayout(self.text_layout, 1)
self.layout.addWidget(self.thumbnail_label)
if callable(download_video_callback):
self.download_button.clicked.connect(download_video_callback)
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)
+332
View File
@@ -0,0 +1,332 @@
import logging
import subprocess
from pathlib import Path
from PySide6.QtWidgets import (
QMainWindow, QWidget,
QLabel, QPushButton, QFrame, QScrollArea,
QVBoxLayout, QHBoxLayout,
QMessageBox, QLineEdit, QDialog, QApplication, )
from PySide6.QtCore import Qt, Signal, QObject
from constant import VERSION
from dialog import DownloadProgressDialog, PlayerDialog, SwitchProfileDialog, PlaylistWindow
from utils import download_file
from widgets import PlaylistItem
from yt_lib import PlaylistFetchException, resolve_video_page_url
logger = logging.getLogger("MainWindow")
class PlaylistMainWindow(QMainWindow):
def __init__(self, app):
super(PlaylistMainWindow, self).__init__()
self.app = app
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.Shape.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.app.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.AlignmentFlag.AlignCenter | Qt.AlignmentFlag.AlignBottom)
self.switch_profile_btn = QPushButton("Switch Profile")
self.helper_key_btn = QPushButton("Helper Secret Key")
self.refresh_playlists_btn = QPushButton("Refresh playlists")
self.save_all_playlists_btn = QPushButton("Save All Playlists")
self.open_download_folder_btn = QPushButton("Open Download Folder")
self.switch_profile_btn.setMinimumHeight(34)
self.helper_key_btn.setMinimumHeight(34)
self.refresh_playlists_btn.setMinimumHeight(34)
self.save_all_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.addLayout(self.playlist_items_layout)
self.playlists_layout.addWidget(self.loading_label, 0, Qt.AlignmentFlag.AlignHCenter)
# 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.helper_key_btn)
self.right_layout.addWidget(self.refresh_playlists_btn)
self.right_layout.addWidget(self.save_all_playlists_btn)
self.right_layout.addWidget(self.open_download_folder_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.signals.download_status.connect(self.loading_label.setText)
self.switch_profile_btn.clicked.connect(self.open_switch_profile_dialog)
self.helper_key_btn.clicked.connect(self.show_helper_secret_key)
self.refresh_playlists_btn.clicked.connect(self.app.reload_playlists)
self.save_all_playlists_btn.clicked.connect(self.save_all_playlists)
self.open_download_folder_btn.clicked.connect(self.app.open_download_folder)
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)
download_status = Signal(str)
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 = PlaylistItem(
self.playlists_frame,
playlist,
title=title,
thumbnail_size=self.app.THUMBNAIL_SIZE
)
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(self.app.temp_path, "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")
def save_all_playlists(self):
playlists = [item for item in self.app.playlists.get("entries", []) if item]
if not playlists:
QMessageBox.information(self, "Download", "No playlists loaded.")
return
self.progress_dialog = DownloadProgressDialog(
"Save All Playlists", 0, self, producers=len(playlists)
)
self.app.download_all_playlists(
self.progress_dialog.finish_future,
self.progress_dialog.tasks_added.emit,
self.progress_dialog.producer_finished.emit,
)
def on_download_done(self, result, video=None, playlist_name=None):
if isinstance(result, Exception):
self.signals.error.emit(f"Download failed: {result}")
return
try:
path = result.result()
except Exception as error:
self.signals.error.emit(f"Download failed: {error}")
return
self.signals.download_status.emit(f"Downloaded: {path.name}")
def clear_layout(self, layout):
while layout.count():
item = layout.takeAt(0)
child_layout = item.layout()
widget = item.widget()
if child_layout:
self.clear_layout(child_layout)
if widget:
widget.deleteLater()
def open_playlist_window(self, playlist: dict):
window = PlaylistWindow(self.app, playlist, parent=self,
open_player_callback=self.open_video_player)
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 = PlayerDialog(self.app, video, parent=self)
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 = SwitchProfileDialog(self.app, parent=self)
dialog.profile_changed.connect(self.apply_profile_switch)
dialog.exec()
def show_helper_secret_key(self):
dialog = QDialog(self)
dialog.setWindowTitle("Helper Secret Key")
layout = QVBoxLayout(dialog)
layout.addWidget(QLabel("Copy this key into the PlaylistSaver Helper settings page:"))
key_field = QLineEdit(self.app.secret_key or "")
key_field.setReadOnly(True)
key_field.setEchoMode(QLineEdit.EchoMode.Password)
layout.addWidget(key_field)
buttons = QHBoxLayout()
reveal_btn = QPushButton("Show")
copy_btn = QPushButton("Copy")
close_btn = QPushButton("Close")
reveal_btn.clicked.connect(lambda: key_field.setEchoMode(QLineEdit.EchoMode.Normal))
copy_btn.clicked.connect(lambda: QApplication.clipboard().setText(key_field.text()))
close_btn.clicked.connect(dialog.accept)
buttons.addWidget(reveal_btn)
buttons.addWidget(copy_btn)
buttons.addWidget(close_btn)
layout.addLayout(buttons)
dialog.resize(540, 140)
dialog.exec()
def apply_profile_switch(self, profile_name: str):
self.app.set_active_profile(profile_name)
self.profile_label.setText(f"Current Profile: {profile_name}")
def closeEvent(self, event):
if not self.app.has_active_download_tasks():
event.accept()
return
answer = QMessageBox.question(
self,
"Downloads in progress",
"Downloads are still running. Close the application and cancel them?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if answer == QMessageBox.StandardButton.Yes:
self.app.cancel_download_tasks()
event.accept()
else:
event.ignore()
+169
View File
@@ -0,0 +1,169 @@
import copy
import logging
import os
import threading
import traceback
import webbrowser
from pathlib import Path
from typing import Callable
from PySide6.QtWidgets import QMessageBox
from yt_dlp import YoutubeDL
from constant import YT_PLAYLISTS_URL
from utils import check_url_scheme, parser_url_scheme, find_http_status
logger = logging.getLogger("PLib")
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 | None=None, video_id: str=None) -> str:
if not video and not video_id:
raise Exception("No video id provided")
if isinstance(video_id, str):
return f"https://www.youtube.com/watch?v={video_id}"
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 fetch_playlists_data(url_schema,
cookie_file_path: Path, opts, callback: Callable,
work_dir: Path, fetch_lock: threading.Lock,
main_filepath: Path, secret_key: str | None = None):
check_url_scheme(main_filepath.absolute().as_posix(), work_dir)
error = None
if url_schema is not None:
try:
parser_url_scheme(url_schema, cookie_path=cookie_file_path, secret_key=secret_key)
except Exception as e:
logger.error("Unable to parse URL scheme: %s", e)
playlists_data = None
with fetch_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[0]}")
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 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