Compare commits

...
14 Commits
Author SHA1 Message Date
wei 636df2eb38 Upload packed version helper. 2026-08-25 18:31:12 +08:00
wei fc15c3ecc3 Update helper version. 2026-08-25 18:28:15 +08:00
wei 827ac87ae1 Upload missing helper files 2026-08-25 18:18:14 +08:00
wei 4e3bebd136 Update README.md 2026-08-25 18:17:12 +08:00
wei 26c3dd78ab Upload Windows (x86 and x86-64) binaries. 2026-08-25 17:54:06 +08:00
wei 5db71932d5 Upload Windows (arm64) binaries. 2026-08-25 17:50:35 +08:00
wei 175c97061d Upload Linux binaries. 2026-08-25 17:48:41 +08:00
wei d3a5f42fb1 Upload macOS binaries. 2026-08-25 17:47:24 +08:00
wei 04e03c132d Add download video and save playlist support. 2026-08-25 17:46:48 +08:00
wei eadc5c6267 Add qt support, some ui design changes and remove batch download support. 2026-06-18 21:16:09 +08:00
wei 76904a62e3 Some preparing for Qt support... 2026-06-15 22:08:02 +08:00
wei fba2ba1221 Yep just added many things. 2026-06-15 01:16:26 +08:00
wei d92f1b0bb8 Optimize playlist thumbnail loading speed. 2026-06-15 00:16:44 +08:00
wei cb335d64df Optimize playlist date download speed. 2026-06-14 22:32:25 +08:00
263 changed files with 3956 additions and 836 deletions

No files matched your search

+8
View File
@@ -1,2 +1,10 @@
/save/ /save/
/temp/ /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",
)
Binary file not shown.
+121 -28
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) => { chrome.commands.onCommand.addListener((command) => {
if (command === "openPLKey") { if (command === "openPLKey") {
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.cookies.getAll({ domain: ".youtube.com" }, (cookies) => { chrome.runtime.onInstalled.addListener(() => {
let output = "# Netscape HTTP Cookie File\n"; chrome.contextMenus.removeAll(() => {
chrome.contextMenus.create({
cookies.forEach(c => { id: "openPlaylistSaver",
const domain = c.domain; title: "Open PlaylistSaver",
const flag = domain.startsWith('.') ? "TRUE" : "FALSE"; contexts: ["page"]
const path = c.path; });
const secure = c.secure ? "TRUE" : "FALSE"; chrome.contextMenus.create({
const expiration = c.expirationDate ? Math.floor(c.expirationDate) : 0; id: "openHelperSettings",
title: "PlaylistSaver Helper Settings",
contexts: ["page"]
});
});
});
output += [ chrome.contextMenus.onClicked.addListener((info) => {
domain, if (info.menuItemId === "openPlaylistSaver") {
flag, launchPlaylistSaver().catch(error => console.error("Unable to launch PlaylistSaver:", error));
path, } else if (info.menuItemId === "openHelperSettings") {
secure, openHelperSettings().catch(error => console.error("Unable to open Helper settings:", error));
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}`
});
});
});
} }
}); });
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

+15 -4
View File
@@ -1,12 +1,13 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "Playlist Helper", "name": "Playlist Helper",
"version": "1.0", "version": "1.0.003",
"permissions": [ "permissions": [
"scripting",
"activeTab", "activeTab",
"cookies" "contextMenus",
"cookies",
"storage"
], ],
"host_permissions": [ "host_permissions": [
"*://*.youtube.com/*" "*://*.youtube.com/*"
@@ -16,11 +17,21 @@
"service_worker": "background.js" "service_worker": "background.js"
}, },
"options_page": "options.html",
"icons": {
"128": "icon.png"
},
"commands": { "commands": {
"openPLKey": { "openPLSettingPageKey": {
"suggested_key": { "suggested_key": {
"default": "Alt+Z" "default": "Alt+Z"
}, },
"description": "Open settings page"
},
"openPLKey": {
"suggested_key": {
"default": "Alt+Y"
},
"description": "Launch PlaylistSaver" "description": "Launch PlaylistSaver"
} }
} }
+10
View File
@@ -0,0 +1,10 @@
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
body { margin: 0; background: #181818; color: #f2f2f2; }
main { width: min(620px, calc(100% - 48px)); margin: 64px auto; padding: 28px; background: #242424; border: 1px solid #404040; border-radius: 12px; }
h1 { margin-top: 0; }
label { display: block; margin: 24px 0 8px; font-weight: 600; }
.key-row, .actions { display: flex; gap: 10px; align-items: center; }
input { flex: 1; min-width: 0; padding: 10px 12px; border: 1px solid #555; border-radius: 7px; font: 14px ui-monospace, monospace; }
button { padding: 10px 14px; border: 1px solid #666; border-radius: 7px; cursor: pointer; }
.actions { margin-top: 16px; }
#status { color: #8bd39a; }
+25
View File
@@ -0,0 +1,25 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PlaylistSaver Helper Settings</title>
<link rel="stylesheet" href="options.css">
</head>
<body>
<main>
<h1>PlaylistSaver Helper</h1>
<p>Copy the secret key from PlaylistSaver and paste it below.</p>
<label for="secretKey">Secret key</label>
<div class="key-row">
<input id="secretKey" type="password" autocomplete="off" spellcheck="false">
<button id="toggleKey" type="button">Show</button>
</div>
<div class="actions">
<button id="saveKey" type="button">Save key</button>
<span id="status" role="status"></span>
</div>
</main>
<script src="options.js"></script>
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
const keyInput = document.querySelector("#secretKey");
const status = document.querySelector("#status");
chrome.storage.local.get(["secretKey", "showMissingKeyAlert"]).then(async ({ secretKey, showMissingKeyAlert }) => {
keyInput.value = secretKey || "";
if (showMissingKeyAlert) {
await chrome.storage.local.remove("showMissingKeyAlert");
alert("Set the PlaylistSaver secret key before exporting cookies.");
keyInput.focus();
}
});
document.querySelector("#toggleKey").addEventListener("click", event => {
const showing = keyInput.type === "text";
keyInput.type = showing ? "password" : "text";
event.currentTarget.textContent = showing ? "Show" : "Hide";
});
document.querySelector("#saveKey").addEventListener("click", async () => {
const secretKey = keyInput.value.trim();
if (!/^[A-Za-z0-9_-]{43}$/.test(secretKey)) {
status.textContent = "Invalid key. Copy it again from PlaylistSaver.";
status.style.color = "#ff8989";
return;
}
await chrome.storage.local.set({ secretKey });
status.textContent = "Secret key saved.";
status.style.color = "#8bd39a";
});
+37 -1
View File
@@ -1,3 +1,39 @@
# PlaylistSaver # PlaylistSaver
A tool to save your youtube playlist. **VERY UNSTABLE** A tool to save your YouTube playlist.
## Run from source
Sync project and install dependencies.
```bash
uv sync
```
Run from source:
```bash
python main.py
```
## Build
> [!IMPORTANT]
> This project does not support Nuitka for building. Some packages used in this project, such as mpv and yt-dlp,
> may not be able to function or may cause crashes in the building process.
Install build tools:
```bash
uv pip install -e ".[build]"
```
Build this project with PyInstaller:
```powershell
.\build.ps1 -Clean
```
The build is written to `dist\PlaylistSaver`. This 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.
## How to use
+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())
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.
View File
Whitespace-only changes.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loaded 100 of 263 files, more files were not shown because too many files have changed in this diff. Show more