Update files

This commit is contained in:
2026-05-16 20:26:51 +08:00
parent da8fbc645d
commit f6b6bfa761
4 changed files with 898 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
/save/
/temp/
+34
View File
@@ -0,0 +1,34 @@
chrome.commands.onCommand.addListener((command) => {
if (command === "openPLKey") {
chrome.cookies.getAll({ domain: ".youtube.com" }, (cookies) => {
let output = "# Netscape HTTP Cookie File\n";
cookies.forEach(c => {
const domain = c.domain;
const flag = domain.startsWith('.') ? "TRUE" : "FALSE";
const path = c.path;
const secure = c.secure ? "TRUE" : "FALSE";
const expiration = c.expirationDate ? Math.floor(c.expirationDate) : 0;
output += [
domain,
flag,
path,
secure,
expiration,
c.name,
c.value
].join("\t") + "\n";
});
const encoded = btoa(output);
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
chrome.tabs.update(tabs[0].id, {
url: `PlaylistSaver://open?base64cookies=${encoded}`
});
});
});
}
});
+27
View File
@@ -0,0 +1,27 @@
{
"manifest_version": 3,
"name": "Playlist Helper",
"version": "1.0",
"permissions": [
"scripting",
"activeTab",
"cookies"
],
"host_permissions": [
"*://*.youtube.com/*"
],
"background": {
"service_worker": "background.js"
},
"commands": {
"openPLKey": {
"suggested_key": {
"default": "Alt+Z"
},
"description": "Launch PlaylistSaver"
}
}
}
+835
View File
@@ -0,0 +1,835 @@
import base64
import binascii
import json
import logging
import os
import shutil
import subprocess
import sys
import threading
import time
from tkinter import messagebox, simpledialog
from typing import Callable
from PIL import Image, ImageTk
import customtkinter as ctk
import urllib
from urllib.parse import unquote
import click
import requests
import winreg
from yt_dlp import YoutubeDL
VERSION = "Prototype"
ROOT_DIR = os.path.dirname(os.path.abspath(__file__))
save_path = os.path.join(ROOT_DIR, "save")
temp_path = os.path.join(ROOT_DIR, "temp")
profiles_path = os.path.join(save_path, "profiles")
profiles_meta_path = os.path.join(save_path, "profiles.json")
DEFAULT_PROFILE_NAME = "default"
active_profile_name = DEFAULT_PROFILE_NAME
cookie_path = os.path.join(profiles_path, DEFAULT_PROFILE_NAME, "cookies.txt")
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
fetch_playlist_lock = threading.Lock()
mpv_processes = {}
download_playlists_lock = threading.Lock()
def key_exists(hive, sub_key):
try:
# Attempt to open the key for reading
with winreg.OpenKey(hive, sub_key, 0, winreg.KEY_READ) as key:
return True
except FileNotFoundError:
return False
def create_url_scheme():
try:
scheme = "PlaylistSaver"
base = r"Software\Classes\\" + scheme
key = winreg.CreateKey(winreg.HKEY_CURRENT_USER, base)
winreg.SetValueEx(key, None, 0, winreg.REG_SZ, f"URL:{scheme} Protocol")
winreg.SetValueEx(key, "URL Protocol", 0, winreg.REG_SZ, "")
shell_key = winreg.CreateKey(key, "shell")
open_key = winreg.CreateKey(shell_key, "open")
command_key = winreg.CreateKey(open_key, "command")
if getattr(sys, 'frozen', False):
command = f"\"{sys.executable}\" \"%1\""
else:
command = f"\"{sys.executable}\" \"{os.path.abspath(__file__)}\" \"%1\""
winreg.SetValueEx(command_key, None, 0, winreg.REG_SZ, command)
logger.info("URL scheme registered.")
except OSError as e:
logger.error("Unable to create URL scheme:", e)
def check_url_scheme():
try:
if not key_exists(winreg.HKEY_LOCAL_MACHINE, "PlaylistSaver"):
create_url_scheme()
except FileNotFoundError:
create_url_scheme()
def read_cookies():
try:
with open(cookie_path, "r", encoding='utf-8') as f:
return f.read()
except FileNotFoundError:
logger.error("Cookies file not found.")
except Exception as e:
logger.error("An unexpected error occurred while reading cookies:", e)
def load_profiles_meta():
meta = read_json(profiles_meta_path)
if not meta:
return {
"active_profile": DEFAULT_PROFILE_NAME,
"profiles": [DEFAULT_PROFILE_NAME],
}
profiles = meta.get("profiles") or [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 save_profiles_meta(meta: dict):
save_json(meta, profiles_meta_path)
def get_profile_dir(profile_name: str):
return os.path.join(profiles_path, sanitize_filename(profile_name))
def get_profile_cookie_path(profile_name: str):
return os.path.join(get_profile_dir(profile_name), "cookies.txt")
def get_active_profile_name():
global active_profile_name
meta = load_profiles_meta()
active_profile_name = meta["active_profile"]
return active_profile_name
def set_active_profile(profile_name: str):
global active_profile_name, cookie_path
meta = load_profiles_meta()
profiles = meta["profiles"]
if profile_name not in profiles:
profiles.append(profile_name)
meta["profiles"] = profiles
meta["active_profile"] = profile_name
save_profiles_meta(meta)
active_profile_name = profile_name
cookie_path = get_profile_cookie_path(profile_name)
os.makedirs(os.path.dirname(cookie_path), exist_ok=True)
def ensure_profile_exists(profile_name: str):
meta = load_profiles_meta()
profiles = meta["profiles"]
if profile_name not in profiles:
profiles.append(profile_name)
meta["profiles"] = profiles
save_profiles_meta(meta)
os.makedirs(get_profile_dir(profile_name), exist_ok=True)
def initialize_profiles():
os.makedirs(profiles_path, exist_ok=True)
active_name = get_active_profile_name()
ensure_profile_exists(active_name)
set_active_profile(active_name)
def get_cookie_owner():
ydl_opts = {
'cookiefile': cookie_path,
'verbose': True,
}
with YoutubeDL(ydl_opts) as ydl:
data = ydl.extract_info(
"https://www.youtube.com/feed/subscriptions",
download=False
)
print("Cookie owner: ", json.dumps(data, indent=2))
def save_cookies(cookies):
try:
os.makedirs(os.path.dirname(cookie_path), exist_ok=True)
with open(cookie_path, "w", encoding='utf-8') as f:
f.write(cookies)
except Exception as e:
logger.error("An unexpected error occurred while saving cookies:", e)
def cookie_string_to_netscape(cookie_str):
lines = ["# Netscape HTTP Cookie File"]
for pair in cookie_str.split(";"):
pair = pair.strip()
if not pair or "=" not in pair:
continue
name, value = pair.split("=", 1) # ← 關鍵修正
lines.append("\t".join([
".youtube.com",
"TRUE",
"/",
"TRUE",
"9999999999",
name.strip(),
value.strip()
]))
return "\n".join(lines)
def pause():
input("Press enter to continue...")
def save_base64_netscape_cookie(cookies_encoded):
try:
cookies = base64.b64decode(cookies_encoded).decode("utf-8")
except binascii.Error:
print("Unable to decode base64 cookie:", cookies_encoded)
return None
print("==== COOKIE FILE ====")
for line in cookies.split("\n"):
print(line, "=>", len(line.split("\t")))
print("=====================")
save_cookies(cookies)
def parser_url_scheme(url):
print("Parsing URL scheme:", url)
url = urllib.parse.urlparse(unquote(sys.argv[1]))
parameters = urllib.parse.parse_qs(url.query)
func_map = {
"base64cookies": save_base64_netscape_cookie,
}
for arg_name in parameters:
match_func = func_map.get(arg_name, None)
if match_func:
match_func(parameters[arg_name][0])
else:
print("Unknown parameter:", arg_name, " Value:", parameters[arg_name])
def save_json(data: dict, dest):
try:
os.makedirs(os.path.dirname(dest), exist_ok=True)
with open(dest, "w", encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=4)
except Exception as e:
logger.error("Unable to save json:", e)
def read_json(path):
try:
with open(path, "r", encoding='utf-8') as f:
return json.load(f)
except Exception as e:
logger.error("Unable to read json:", e)
def load_playlists():
playlists_path = get_profile_playlists_cache_path()
if not os.path.exists(playlists_path):
logger.error("Playlists file not found.")
return None
return read_json(playlists_path)
def get_ydl_opts():
return {
'cookiefile': cookie_path,
'verbose': True,
'download': False,
'js_runtimes': {"node": {}},
'extract_flat': False,
'lazy_playlist': False,
"ignoreerrors": True
}
def get_profile_temp_dir(profile_name: str | None = None):
profile_name = profile_name or active_profile_name
return os.path.join(temp_path, sanitize_filename(profile_name))
def get_profile_playlists_cache_path(profile_name: str | None = None):
return os.path.join(get_profile_temp_dir(profile_name), "cache-playlists.json")
def sanitize_filename(value: str):
invalid_chars = '<>:"/\\|?*'
return "".join("_" if char in invalid_chars else char for char in value)
def get_playlist_cache_path(playlist_id: str):
safe_id = sanitize_filename(playlist_id or "unknown")
return os.path.join(get_profile_temp_dir(), "playlists", f"{safe_id}.json")
def get_playlist_batch_progress_path():
return os.path.join(get_profile_temp_dir(), "playlist-download-progress.json")
def load_playlist_batch_progress():
progress_path = get_playlist_batch_progress_path()
if os.path.exists(progress_path):
return read_json(progress_path)
return {
"profile": get_active_profile_name(),
"completed_ids": [],
"failed": [],
"last_index": -1,
"updated_at": None,
}
def save_playlist_batch_progress(progress: dict):
progress["profile"] = get_active_profile_name()
progress["updated_at"] = int(time.time())
save_json(progress, get_playlist_batch_progress_path())
def load_playlist_videos(playlist_id: str):
cache_path = get_playlist_cache_path(playlist_id)
if os.path.exists(cache_path):
return read_json(cache_path)
return None
def save_playlist_videos(playlist_id: str, data: dict):
save_json(data, get_playlist_cache_path(playlist_id))
def fetch_playlist_videos(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 = load_playlist_videos(playlist_id)
if cached_data:
return cached_data
with fetch_playlist_lock:
with YoutubeDL(get_ydl_opts()) as ydl:
data = ydl.extract_info(playlist_url, download=False)
save_playlist_videos(playlist_id, data)
return data
def resolve_video_page_url(video: dict):
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 resolve_mpv_path():
local_candidates = [
os.path.join(ROOT_DIR, "bin", "mpv.exe"),
os.path.join(ROOT_DIR, "bin", "mpv.com"),
os.path.join(ROOT_DIR, "bin", "mpv"),
]
for candidate in local_candidates:
if os.path.exists(candidate):
return candidate
# failback to PATH mpv (if available)
return shutil.which("mpv")
def play_video_with_mpv(video_url: str, window: ctk.CTkToplevel, status_label: ctk.CTkLabel):
mpv_path = resolve_mpv_path()
if not mpv_path:
window.after(0, lambda: status_label.configure(text="mpv not found in bin/ or PATH."))
return
try:
process = subprocess.Popen(
[mpv_path, 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)
window.after(0, lambda: status_label.configure(text=f"Unable to start mpv: {e}"))
return
mpv_processes[window] = process
window.after(0, lambda: status_label.configure(text="Playing in mpv..."))
process.wait()
def on_finish():
mpv_processes.pop(window, None)
if window.winfo_exists():
status_label.configure(text="Playback finished.")
window.after(0, on_finish)
def download_file(url, dest):
try:
os.makedirs(os.path.dirname(dest), exist_ok=True)
with requests.get(url, stream=True) as r:
with open(dest, "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:", e)
return False
def background(url_schema, cookie_file: str, callback: Callable):
global cookie_path
check_url_scheme()
initialize_profiles()
if url_schema is not None:
try:
parser_url_scheme(url_schema)
except Exception as e:
logger.error("Unable to parse URL scheme:", e)
input("Press enter to continue...")
if cookie_file is not None:
cookie_path = cookie_file
data = load_playlists()
if not data:
# messagebox.showerror("Error", "No playlists found.")
with fetch_playlist_lock:
try:
with YoutubeDL(get_ydl_opts()) as ydl:
data = ydl.extract_info("https://www.youtube.com/feed/playlists", download=False)
except Exception as e:
print("An error occurred:", e)
pause()
sys.exit(-1)
# print("=== YOUTUBE DATA ===")
# print(json.dumps(data, indent=4))
# print("=== YOUTUBE DATA ===")
save_json(data, get_profile_playlists_cache_path())
callback(data)
@click.command()
@click.argument("url_schema", default=None, required=False)
@click.option("--cookie-file", "-cf", required=False, default=None, help="Cookies file",
type=click.Path(exists=True))
def main(url_schema, cookie_file: str):
initialize_profiles()
current_playlists_data = {"entries": []}
def on_data_ready(data):
current_playlists_data["entries"] = data.get("entries", [])
root.after(0, lambda: build_ui(data))
# CTk
root = ctk.CTk()
root.geometry("600x800")
root.title("PlaylistSaver {}".format(VERSION))
# Layout
playlist_frame = ctk.CTkScrollableFrame(root)
playlist_frame.pack(fill="both", expand=True)
operate_frame = ctk.CTkFrame(root)
operate_frame.pack(fill="x", anchor="s")
profile_label = ctk.CTkLabel(operate_frame, text=f"Current Profile: {get_active_profile_name()}")
profile_label.pack(fill="x", padx=10, pady=(10, 5))
switch_profile_btn = ctk.CTkButton(operate_frame, text="Switch Profile")
switch_profile_btn.pack(fill="x", padx=10, pady=(0, 10))
download_status_label = ctk.CTkLabel(operate_frame, text="Playlist batch download idle.")
download_status_label.pack(fill="x", padx=10, pady=(0, 5))
batch_download_btn = ctk.CTkButton(operate_frame, text="Batch Download Playlists")
batch_download_btn.pack(fill="x", padx=10, pady=(0, 10))
loading_label = ctk.CTkLabel(playlist_frame, text="Loading playlists...")
loading_label.pack(pady=20)
# Bind specified callback func
def bind_click_recursive(widget, callback):
widget.bind("<Button-1>", callback)
for child in widget.winfo_children():
bind_click_recursive(child, callback)
def refresh_profile_label():
profile_label.configure(text=f"Current Profile: {get_active_profile_name()}")
def update_download_status(message: str):
root.after(0, lambda: download_status_label.configure(text=message))
def reload_playlists(current_url_schema=None, current_cookie_file=None):
for widget in playlist_frame.winfo_children():
widget.destroy()
ctk.CTkLabel(
playlist_frame,
text=f"Loading playlists for {get_active_profile_name()}...",
).pack(pady=20)
threading.Thread(
target=background,
args=(current_url_schema, current_cookie_file, on_data_ready),
daemon=True,
).start()
def switch_to_profile(profile_name: str, window: ctk.CTkToplevel | None = None):
set_active_profile(profile_name)
refresh_profile_label()
if window and window.winfo_exists():
window.destroy()
reload_playlists()
def open_switch_profile_window():
profile_window = ctk.CTkToplevel(root)
profile_window.title("Switch Profile")
profile_window.geometry("420x420")
ctk.CTkLabel(
profile_window,
text=f"Active: {get_active_profile_name()}",
).pack(anchor="w", padx=15, pady=(15, 10))
list_frame = ctk.CTkScrollableFrame(profile_window)
list_frame.pack(fill="both", expand=True, padx=15, pady=(0, 10))
def render_profiles():
for widget in list_frame.winfo_children():
widget.destroy()
meta = load_profiles_meta()
active_name = meta["active_profile"]
for profile_name in meta["profiles"]:
profile_item = ctk.CTkFrame(list_frame, border_width=1, border_color="#666666")
profile_item.pack(fill="x", padx=5, pady=5)
ctk.CTkLabel(
profile_item,
text=profile_name,
).pack(side="left", padx=10, pady=10)
button_text = "Current" if profile_name == active_name else "Switch"
button_state = "disabled" if profile_name == active_name else "normal"
ctk.CTkButton(
profile_item,
text=button_text,
width=90,
state=button_state,
command=lambda current_profile=profile_name: switch_to_profile(current_profile, profile_window),
).pack(side="right", padx=10, pady=10)
def create_profile():
new_profile_name = simpledialog.askstring(
"Create Profile",
"Profile name:",
parent=profile_window,
)
if not new_profile_name:
return
new_profile_name = new_profile_name.strip()
if not new_profile_name:
messagebox.showerror("Error", "Profile name cannot be empty.")
return
ensure_profile_exists(new_profile_name)
render_profiles()
ctk.CTkButton(
profile_window,
text="Create Profile",
command=create_profile,
).pack(fill="x", padx=15, pady=(0, 15))
render_profiles()
switch_profile_btn.configure(command=open_switch_profile_window)
def start_batch_download():
playlists = current_playlists_data.get("entries") or []
if not playlists:
messagebox.showerror("Error", "No playlists loaded yet.")
return
batch_size = simpledialog.askinteger(
"Batch Download",
"Batch size:",
parent=root,
initialvalue=5,
minvalue=1,
)
if batch_size is None:
return
progress = load_playlist_batch_progress()
completed_ids = set(progress.get("completed_ids", []))
pending_playlists = [playlist for playlist in playlists if playlist.get("id") not in completed_ids]
if not pending_playlists:
if not messagebox.askyesno("Batch Download", "All playlists are already cached. Download again from scratch?"):
return
progress = {
"profile": get_active_profile_name(),
"completed_ids": [],
"failed": [],
"last_index": -1,
"updated_at": None,
}
save_playlist_batch_progress(progress)
pending_playlists = playlists
batch_download_btn.configure(state="disabled")
update_download_status(f"Starting batch download ({len(pending_playlists)} playlists pending)...")
def worker():
with download_playlists_lock:
progress_data = load_playlist_batch_progress()
completed = set(progress_data.get("completed_ids", []))
failed_entries = progress_data.get("failed", [])
for batch_start in range(0, len(playlists), batch_size):
batch = playlists[batch_start:batch_start + batch_size]
batch_index = (batch_start // batch_size) + 1
total_batches = (len(playlists) + batch_size - 1) // batch_size
update_download_status(f"Downloading batch {batch_index}/{total_batches}...")
for index_in_batch, playlist in enumerate(batch, start=batch_start):
playlist_id = playlist.get("id", "unknown")
playlist_title = playlist.get("title", playlist_id)
if playlist_id in completed:
progress_data["last_index"] = index_in_batch
save_playlist_batch_progress(progress_data)
continue
update_download_status(f"Saving playlist {index_in_batch + 1}/{len(playlists)}: {playlist_title}")
try:
fetch_playlist_videos(playlist)
completed.add(playlist_id)
progress_data["completed_ids"] = list(completed)
progress_data["failed"] = [item for item in failed_entries if item.get("id") != playlist_id]
except Exception as e:
logger.error("Batch download failed for %s: %s", playlist_title, e)
failed_entries = [item for item in failed_entries if item.get("id") != playlist_id]
failed_entries.append({
"id": playlist_id,
"title": playlist_title,
"error": str(e),
})
progress_data["failed"] = failed_entries
finally:
progress_data["last_index"] = index_in_batch
save_playlist_batch_progress(progress_data)
failed_count = len(progress_data.get("failed", []))
finished_message = (
f"Batch download finished. Success: {len(progress_data.get('completed_ids', []))}, "
f"Failed: {failed_count}"
)
update_download_status(finished_message)
root.after(0, lambda: batch_download_btn.configure(state="normal"))
threading.Thread(target=worker, daemon=True).start()
batch_download_btn.configure(command=start_batch_download)
# Start background thread after root is ready for UI callbacks.
reload_playlists(url_schema, cookie_file)
def close_video_window(video_window):
process = mpv_processes.pop(video_window, None)
if process and process.poll() is None:
process.terminate()
if video_window.winfo_exists():
video_window.destroy()
def open_video_player(video: dict):
video_url = resolve_video_page_url(video)
video_title = video.get("title", "Video not found")
if not video_url:
messagebox.showerror("Error", "Unable to resolve video URL.")
return
player_window = ctk.CTkToplevel(root)
player_window.title(video_title)
player_window.geometry("420x180")
ctk.CTkLabel(
player_window,
text=video_title,
wraplength=360,
justify="left",
).pack(anchor="w", padx=15, pady=(15, 10))
status_label = ctk.CTkLabel(player_window, text="Launching mpv...")
status_label.pack(anchor="w", padx=15, pady=(0, 10))
ctk.CTkLabel(
player_window,
text=video_url,
wraplength=360,
justify="left",
).pack(anchor="w", padx=15, pady=(0, 15))
close_button = ctk.CTkButton(
player_window,
text="Close Player",
command=lambda: close_video_window(player_window),
)
close_button.pack(fill="x", padx=15, pady=(0, 15))
player_window.protocol("WM_DELETE_WINDOW", lambda: close_video_window(player_window))
threading.Thread(
target=play_video_with_mpv,
args=(video_url, player_window, status_label),
daemon=True,
).start()
def open_playlist_window(playlist: dict):
playlist_title = playlist.get("title", "Playlist not found")
# playlist_id = playlist.get("id", "unknown")
playlist_window = ctk.CTkToplevel(root)
playlist_window.title(playlist_title)
playlist_window.geometry("700x600")
header_label = ctk.CTkLabel(playlist_window, text=playlist_title)
header_label.pack(anchor="w", padx=15, pady=(15, 10))
info_label = ctk.CTkLabel(playlist_window, text="Loading playlist videos...")
info_label.pack(anchor="w", padx=15, pady=(0, 10))
video_frame = ctk.CTkScrollableFrame(playlist_window)
video_frame.pack(fill="both", expand=True, padx=15, pady=(0, 15))
def render_videos(playlist_data):
for widget in video_frame.winfo_children():
widget.destroy()
entries = playlist_data.get("entries") or []
info_label.configure(text=f"{len(entries)} videos")
if not entries:
ctk.CTkLabel(video_frame, text="No videos found.").pack(anchor="w", padx=10, pady=10)
return
for index, video in enumerate(entries, start=1):
video_title = video.get("title", "Video not found")
duration = video.get("duration_string") or "Unknown duration"
video_item = ctk.CTkFrame(video_frame, border_width=1, border_color="#666666")
video_item.pack(fill="x", padx=5, pady=5)
ctk.CTkLabel(
video_item,
text=f"{index}. {video_title}",
wraplength=600,
justify="left",
).pack(anchor="w", padx=10, pady=(10, 5))
ctk.CTkLabel(
video_item,
text=duration,
text_color="#aaaaaa",
).pack(anchor="w", padx=10, pady=(0, 10))
# Open video player (mpv) when video_frame is clicked
bind_click_recursive(video_item, lambda _event, current_video=video: open_video_player(current_video))
def worker():
try:
playlist_data = fetch_playlist_videos(playlist)
except Exception as e:
logger.error("Unable to load playlist videos: %s", e)
playlist_window.after(0, lambda: info_label.configure(text=f"Unable to load playlist: {e}"))
return
playlist_window.after(0, lambda: render_videos(playlist_data))
threading.Thread(target=worker, daemon=True).start()
# Load playlist when background thread finished playlists data fetch process
def build_ui(playlists_data):
for widget in playlist_frame.winfo_children():
widget.destroy()
playlists = playlists_data.get("entries", [])
for playlist in playlists:
title = playlist.get("title", "Title not found")
id = playlist.get("id", "ID not found")
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 = os.path.join(ROOT_DIR, "temp", "thumbnails",
f"thumbnail_{id}_{thumbnail_resolution}.jpg")
result = download_file(thumbnail_url, thumbnail_path)
if result:
image = Image.open(thumbnail_path)
thumbnail = ctk.CTkImage(image, size=(60, 30))
else:
thumbnail = None
else:
thumbnail = None
playlist_item = ctk.CTkFrame(playlist_frame, border_width=1, border_color="#666666",
height=120)
title_label = ctk.CTkLabel(playlist_item, text=title)
title_label.pack(anchor="w", padx=10, pady=10)
# Don't apply thumbnail if not available
if thumbnail:
thumbnail_label = ctk.CTkLabel(playlist_item, image=thumbnail)
thumbnail_label.pack(anchor="e", padx=5, pady=5)
bind_click_recursive(playlist_item, lambda _event, current_playlist=playlist: open_playlist_window(current_playlist))
playlist_item.pack(padx=5, pady=5, expand=True, fill="x")
root.mainloop()
if __name__ == "__main__":
main()