Add download video and save playlist support.
This commit is contained in:
@@ -2,3 +2,9 @@
|
|||||||
/temp/
|
/temp/
|
||||||
/main.build/
|
/main.build/
|
||||||
/main.dist/
|
/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",
|
||||||
|
)
|
||||||
|
|
||||||
@@ -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 |
@@ -4,9 +4,10 @@
|
|||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
|
|
||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,30 @@
|
|||||||
# PlaylistSaver
|
# 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.
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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"
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -1006,7 +1006,7 @@ def main(url_schema, cookie_file: str):
|
|||||||
|
|
||||||
thumbnail_path = Path(
|
thumbnail_path = Path(
|
||||||
ROOT_DIR,
|
ROOT_DIR,
|
||||||
"temp",
|
"../temp",
|
||||||
"thumbnails",
|
"thumbnails",
|
||||||
f"thumbnail_{video.id}_{thumbnail_resolution}.jpg"
|
f"thumbnail_{video.id}_{thumbnail_resolution}.jpg"
|
||||||
)
|
)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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"}
|
||||||
|
|
||||||
@@ -1,15 +1,20 @@
|
|||||||
import base64
|
import base64
|
||||||
import binascii
|
import binascii
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
import json
|
import json
|
||||||
import platform
|
import platform
|
||||||
import shutil
|
import shutil
|
||||||
import urllib
|
import urllib
|
||||||
import winreg
|
try:
|
||||||
|
import winreg
|
||||||
|
except ImportError: # Windows-only standard library module
|
||||||
|
winreg = None
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import unquote
|
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
|
import requests
|
||||||
|
|
||||||
logger = logging.getLogger("PlaylistSaver.Utils")
|
logger = logging.getLogger("PlaylistSaver.Utils")
|
||||||
logger.setLevel(logging.INFO)
|
logger.setLevel(logging.INFO)
|
||||||
@@ -20,6 +25,9 @@ def set_info(target_logger):
|
|||||||
logger = target_logger
|
logger = target_logger
|
||||||
|
|
||||||
def key_exists(hive, sub_key):
|
def key_exists(hive, sub_key):
|
||||||
|
if winreg is None:
|
||||||
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Attempt to open the key for reading
|
# Attempt to open the key for reading
|
||||||
with winreg.OpenKey(hive, sub_key, 0, winreg.KEY_READ) as key:
|
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")
|
command_key = winreg.CreateKey(open_key, "command")
|
||||||
|
|
||||||
if getattr(sys, 'frozen', False):
|
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:
|
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)
|
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:
|
except OSError as e:
|
||||||
logger.error("Unable to create URL scheme: %s", 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:
|
try:
|
||||||
if not key_exists(winreg.HKEY_LOCAL_MACHINE, "PlaylistSaver"):
|
if not key_exists(winreg.HKEY_CURRENT_USER, r"Software\Classes\PlaylistSaver"):
|
||||||
create_url_scheme(main_file_path, work_dir)
|
create_url_scheme(executable_path, work_dir)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
create_url_scheme(main_file_path, work_dir)
|
create_url_scheme(executable_path, work_dir)
|
||||||
|
|
||||||
def read_cookies(cookie_path):
|
def read_cookies(cookie_path):
|
||||||
try:
|
try:
|
||||||
@@ -84,19 +95,54 @@ def save_base64_netscape_cookie(cookies_encoded, cookie_path):
|
|||||||
logger.error("Unable to decode base64 cookie: %s", cookies_encoded)
|
logger.error("Unable to decode base64 cookie: %s", cookies_encoded)
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.debug("==== COOKIE FILE ====")
|
|
||||||
for line in cookies.split("\n"):
|
|
||||||
print(line, "=>", len(line.split("\t")))
|
|
||||||
logger.debug("=====================")
|
|
||||||
|
|
||||||
save_cookies(cookies, cookie_path)
|
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)
|
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)
|
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 = {
|
func_map = {
|
||||||
"base64cookies": {
|
"base64cookies": {
|
||||||
"func": save_base64_netscape_cookie,
|
"func": save_base64_netscape_cookie,
|
||||||
@@ -133,27 +179,33 @@ def find_mpv_path(program_dir: Path):
|
|||||||
platform_name = platform.system().lower()
|
platform_name = platform.system().lower()
|
||||||
machine = platform.machine().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"))
|
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"))
|
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"))
|
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"))
|
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"))
|
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"))
|
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"))
|
local_candidates.append(Path(program_dir, "bin", "mpv", "linux", "arm", "mpv"))
|
||||||
elif platform.system().lower() == "linux" and machine in {"x86", "i386", "i686"}:
|
elif platform_name == "linux" and machine in {"x86", "i386", "i686"}:
|
||||||
local_candidates.append(Path(program_dir, "bin", "mpv", "linux", "x86", "mpv.exe"))
|
local_candidates.append(Path(program_dir, "bin", "mpv", "linux", "x86", "mpv"))
|
||||||
|
|
||||||
for candidate in local_candidates:
|
for candidate in local_candidates:
|
||||||
if candidate.exists() and candidate.is_file():
|
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
|
return candidate
|
||||||
|
|
||||||
# failback to PATH mpv (if available)
|
# failback to PATH mpv (if available)
|
||||||
@@ -201,4 +253,18 @@ def cookie_string_to_netscape(cookie_str):
|
|||||||
value.strip()
|
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
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user