diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..18e3a2c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/save/ +/temp/ diff --git a/PlaylistSaverHelper/background.js b/PlaylistSaverHelper/background.js new file mode 100644 index 0000000..1368d98 --- /dev/null +++ b/PlaylistSaverHelper/background.js @@ -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}` + }); + }); + }); + } +}); \ No newline at end of file diff --git a/PlaylistSaverHelper/manifest.json b/PlaylistSaverHelper/manifest.json new file mode 100644 index 0000000..1833900 --- /dev/null +++ b/PlaylistSaverHelper/manifest.json @@ -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" + } + } +} \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..9091b22 --- /dev/null +++ b/main.py @@ -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("", 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()