Compare commits
17
Commits
f6b6bfa761
...
v0.0.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
636df2eb38 | ||
|
|
fc15c3ecc3 | ||
|
|
827ac87ae1 | ||
|
|
4e3bebd136 | ||
|
|
26c3dd78ab | ||
|
|
5db71932d5 | ||
|
|
175c97061d | ||
|
|
d3a5f42fb1 | ||
|
|
04e03c132d | ||
|
|
eadc5c6267 | ||
|
|
76904a62e3 | ||
|
|
fba2ba1221 | ||
|
|
d92f1b0bb8 | ||
|
|
cb335d64df | ||
|
|
60e4e30ec3 | ||
|
|
cc879c7f3e | ||
|
|
9d923c9d95 |
No files matched your search
@@ -1,2 +1,10 @@
|
|||||||
/save/
|
/save/
|
||||||
/temp/
|
/temp/
|
||||||
|
/main.build/
|
||||||
|
/main.dist/
|
||||||
|
/build/
|
||||||
|
/dist/
|
||||||
|
/*.egg-info/
|
||||||
|
/.idea/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
@@ -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.
@@ -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));
|
||||||
chrome.cookies.getAll({ domain: ".youtube.com" }, (cookies) => {
|
} else if (command === "openPLSettingPageKey") {
|
||||||
let output = "# Netscape HTTP Cookie File\n";
|
openHelperSettings().catch(error => console.error("Unable to open Helper settings:", error));
|
||||||
|
|
||||||
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}`
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 |
@@ -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,12 +17,22 @@
|
|||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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; }
|
||||||
@@ -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>
|
||||||
@@ -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";
|
||||||
|
});
|
||||||
@@ -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
|
||||||
@@ -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())
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 372 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
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
Reference in new issue
Block a user