From b29e50afa12153847513354287ca6570a32319cc Mon Sep 17 00:00:00 2001 From: xRangerDE Date: Wed, 4 Feb 2026 12:30:52 +0100 Subject: [PATCH] Initial commit: Twitch VOD Manager v3.5.3 - Main application with auto-update functionality - PyInstaller spec for building standalone EXE - Inno Setup installer script with silent update support - Server version.json for update checking Features: - Download Twitch VODs - Auto-update with silent installation - Settings stored in ProgramData Co-Authored-By: Claude Opus 4.5 --- .gitignore | 21 + Twitch_VOD_Manager.spec | 49 + Twitch_VOD_Manager_V_3.5.3.pyw | 2535 ++++++++++++++++++++++++++++++++ installer.iss | 70 + server_files/version.json | 5 + 5 files changed, 2680 insertions(+) create mode 100644 .gitignore create mode 100644 Twitch_VOD_Manager.spec create mode 100644 Twitch_VOD_Manager_V_3.5.3.pyw create mode 100644 installer.iss create mode 100644 server_files/version.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..591ef3d --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Build artifacts +build/ +dist/ +installer_output/ +__pycache__/ +*.pyc + +# Config with credentials +config.json + +# Temp files +*.log +nul +download_queue.json + +# IDE +.vscode/ +.idea/ + +# Old exe +Twitch_VOD_Manager.exe diff --git a/Twitch_VOD_Manager.spec b/Twitch_VOD_Manager.spec new file mode 100644 index 0000000..b671eb0 --- /dev/null +++ b/Twitch_VOD_Manager.spec @@ -0,0 +1,49 @@ +# -*- mode: python ; coding: utf-8 -*- +from PyInstaller.utils.hooks import collect_all + +datas = [] +binaries = [] +hiddenimports = [] +tmp_ret = collect_all('customtkinter') +datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] +tmp_ret = collect_all('cv2') +datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] +tmp_ret = collect_all('imageio_ffmpeg') +datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] + + +a = Analysis( + ['Twitch_VOD_Manager_V_3.5.3.pyw'], + pathex=[], + binaries=binaries, + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='Twitch_VOD_Manager', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/Twitch_VOD_Manager_V_3.5.3.pyw b/Twitch_VOD_Manager_V_3.5.3.pyw new file mode 100644 index 0000000..d55db7a --- /dev/null +++ b/Twitch_VOD_Manager_V_3.5.3.pyw @@ -0,0 +1,2535 @@ +import sys +import subprocess +import os +import datetime +import threading +import time +import json +import traceback +import re +import tkinter as tk +from tkinter import messagebox, filedialog +from io import BytesIO +from concurrent.futures import ThreadPoolExecutor +from typing import Optional, Dict, Any, List, Callable + +# ========================================== +# 0. CONFIG & SETUP +# ========================================== +APP_VERSION = "v3.5.3" +UPDATE_CHECK_URL = "http://24-music.de/version.json" +# Programmverzeichnis ermitteln (funktioniert auch bei EXE) +PROGRAM_DIR = os.path.dirname(os.path.abspath(sys.argv[0])) +# Settings in ProgramData speichern (für Installer-Installation) +APPDATA_DIR = os.path.join(os.environ.get('PROGRAMDATA', 'C:\\ProgramData'), 'Twitch_VOD_Manager') +if not os.path.exists(APPDATA_DIR): + try: + os.makedirs(APPDATA_DIR, exist_ok=True) + except: + APPDATA_DIR = PROGRAM_DIR # Fallback auf Programmverzeichnis +CONFIG_FILE = os.path.join(APPDATA_DIR, "config.json") +QUEUE_FILE = os.path.join(APPDATA_DIR, "download_queue.json") +# Standard Download-Ordner auf Desktop +DEFAULT_DOWNLOAD_PATH = os.path.join(os.path.expanduser("~"), "Desktop", "Twitch_VODs") +ESTIMATED_BYTES_PER_SEC = 750 * 1024 +os.environ["OPENCV_LOG_LEVEL"] = "OFF" + +# ========================================== +# CONSTANTS +# ========================================== +# Windows Process Creation Flag +CREATE_NO_WINDOW = 0x08000000 + +# File Size Thresholds (MB) +MIN_VALID_FILE_SIZE_MB = 1.0 +MIN_PART_SIZE_MB = 15.0 + +# Timeouts (Sekunden) +THUMBNAIL_TIMEOUT = 5 +API_TIMEOUT = 10 +YOUTUBE_LOGIN_TIMEOUT = 180 +YOUTUBE_UPLOAD_TIMEOUT = 7200 + +# Retry Settings +MAX_RETRY_ATTEMPTS = 3 +RETRY_DELAY_SECONDS = 5 + +# Thread Pool Settings +MAX_THUMBNAIL_WORKERS = 8 + +# ========================================== +# THEME DEFINITIONS +# ========================================== +THEMES = { + "Default": { + "bg_main": "#242424", "bg_sidebar": "#2b2b2b", "bg_card": "#333333", + "accent": "#1f538d", "accent_hover": "#14375e", "text": "#DCE4EE", + "text_secondary": "#888888", "tab_text_active": "white", + "border_width": 0, "border_color": "#333333", + "button_border_width": 1, "button_border_color": "#444444", + "corner_radius": 8, "button_corner_radius": 8, "entry_corner_radius": 6, + "font_family": "Segoe UI", "font_size_title": 22, "font_size_normal": 14, "font_size_small": 12, + }, + "Discord": { + "bg_main": "#36393f", "bg_sidebar": "#202225", "bg_card": "#2f3136", + "accent": "#5865F2", "accent_hover": "#4752C4", "text": "#dcddde", + "text_secondary": "#72767d", "tab_text_active": "white", + "border_width": 0, "border_color": "#2f3136", + "button_border_width": 0, "button_border_color": "transparent", + "corner_radius": 4, "button_corner_radius": 3, "entry_corner_radius": 3, + "font_family": "Whitney", "font_size_title": 20, "font_size_normal": 14, "font_size_small": 12, + }, + "Twitch": { + "bg_main": "#0e0e10", "bg_sidebar": "#18181b", "bg_card": "#1f1f23", + "accent": "#9146FF", "accent_hover": "#772ce8", "text": "#efeff1", + "text_secondary": "#adadb8", "tab_text_active": "white", + "border_width": 0, "border_color": "#1f1f23", + "button_border_width": 1, "button_border_color": "#9146FF", + "corner_radius": 4, "button_corner_radius": 4, "entry_corner_radius": 4, + "font_family": "Inter", "font_size_title": 22, "font_size_normal": 14, "font_size_small": 12, + }, + "YouTube": { + "bg_main": "#0f0f0f", "bg_sidebar": "#0f0f0f", "bg_card": "#1e1e1e", + "accent": "#FF0000", "accent_hover": "#cc0000", "text": "#ffffff", + "text_secondary": "#aaaaaa", "tab_text_active": "white", + "border_width": 2, "border_color": "#333333", + "button_border_width": 1, "button_border_color": "#333333", + "corner_radius": 12, "button_corner_radius": 18, "entry_corner_radius": 8, + "font_family": "Roboto", "font_size_title": 24, "font_size_normal": 14, "font_size_small": 12, + }, + "Apple": { + "bg_main": "#1c1c1e", "bg_sidebar": "#2c2c2e", "bg_card": "#3a3a3c", + "accent": "#0A84FF", "accent_hover": "#0071e3", "text": "#f5f5f7", + "text_secondary": "#86868b", "tab_text_active": "white", + "border_width": 0, "border_color": "#48484a", + "button_border_width": 1, "button_border_color": "#48484a", + "corner_radius": 14, "button_corner_radius": 12, "entry_corner_radius": 10, + "font_family": "SF Pro Display", "font_size_title": 22, "font_size_normal": 15, "font_size_small": 13, + }, + "Apple Light": { + "bg_main": "#f5f5f7", "bg_sidebar": "#e8e8ed", "bg_card": "#ffffff", + "accent": "#007AFF", "accent_hover": "#0051a8", "text": "#1d1d1f", + "text_secondary": "#86868b", "tab_text_active": "white", + "border_width": 1, "border_color": "#d2d2d7", + "button_border_width": 1, "button_border_color": "#c7c7cc", + "corner_radius": 14, "button_corner_radius": 12, "entry_corner_radius": 10, + "font_family": "SF Pro Display", "font_size_title": 22, "font_size_normal": 15, "font_size_small": 13, + } +} + +# ========================================== +# HELPER FUNCTIONS +# ========================================== +def log_crash(e, filename="CRASH_LOG.txt"): + error_msg = f"ZEIT: {datetime.datetime.now()}\nERROR: {e}\n\n{traceback.format_exc()}" + try: + with open(filename, "w", encoding="utf-8") as f: + f.write(error_msg) + except: + pass + print("CRASH:", e) + +def get_streamlink_cmd(): + """Gibt den Streamlink-Befehl zurück (funktioniert auch als EXE).""" + import shutil + # Versuche streamlink direkt zu finden + streamlink_path = shutil.which("streamlink") + if streamlink_path: + return [streamlink_path] + # Fallback: Python Scripts Ordner + if os.name == 'nt': + scripts_path = os.path.join(os.path.dirname(sys.executable), "Scripts", "streamlink.exe") + if os.path.exists(scripts_path): + return [scripts_path] + # Letzter Fallback: python -m streamlink (funktioniert nur als .pyw) + return [sys.executable, "-m", "streamlink"] + +def install_dependencies(): + if getattr(sys, 'frozen', False): + return + required_map = { + "requests": "requests", + "customtkinter": "customtkinter", + "streamlink": "streamlink", + "packaging": "packaging", + "Pillow": "PIL", + "imageio-ffmpeg": "imageio_ffmpeg", + "opencv-python": "cv2", + "selenium": "selenium" + } + missing = [] + for pkg, imp in required_map.items(): + try: + __import__(imp) + except ImportError: + missing.append(pkg) + + if missing: + print(f"Installiere fehlende Pakete: {missing}...") + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + try: + subprocess.call([sys.executable, "-m", "pip", "install", *missing], stdout=subprocess.PIPE, stderr=subprocess.PIPE, startupinfo=startupinfo, creationflags=CREATE_NO_WINDOW if os.name == 'nt' else 0) + except: + pass + +# Abhängigkeiten prüfen +try: + install_dependencies() +except: + pass + +try: + import requests + import customtkinter as ctk + from PIL import Image, ImageTk + import imageio_ffmpeg + import cv2 + from selenium import webdriver + from selenium.webdriver.common.by import By + from selenium.webdriver.firefox.service import Service + from selenium.webdriver.firefox.options import Options +except Exception as e: + log_crash(f"Import Fehler: {e}") + # GUI Fallback falls möglich, sonst exit + import tkinter + root = tkinter.Tk() + root.withdraw() + messagebox.showerror("Fatal Error", f"Fehler beim Starten:\n{e}\n\nSiehe CRASH_LOG.txt") + sys.exit() + +ctk.set_appearance_mode("Dark") +ctk.set_default_color_theme("dark-blue") + +# ========================================== +# CUSTOM WIDGETS +# ========================================== +class VideoTimeline(tk.Canvas): + def __init__(self, master, width=800, height=40, bg="#1a1a1a", select_color="#E5A00D", command=None, **kwargs): + super().__init__(master, width=width, height=height, bg=bg, highlightthickness=0, **kwargs) + self.command = command + self.select_color = select_color + self.width, self.height = width, height + self.start_pos, self.end_pos = 0.0, 1.0 + self.dragging = None + self.bind("", self.on_resize) + self.bind("", self.on_click) + self.bind("", self.on_drag) + self.bind("", self.on_release) + self.draw() + + def on_resize(self, event): + self.width, self.height = event.width, event.height + self.draw() + + def draw(self): + self.delete("all") + x_s = self.start_pos * self.width + x_e = self.end_pos * self.width + self.create_rectangle(0, 10, self.width, self.height-10, fill="#333333", width=0) + self.create_rectangle(x_s, 10, x_e, self.height-10, fill=self.select_color, width=0) + self.create_rectangle(x_s-5, 5, x_s+5, self.height-5, fill="white", outline="gray", width=1) + self.create_rectangle(x_e-5, 5, x_e+5, self.height-5, fill="white", outline="gray", width=1) + + def on_click(self, event): + x = event.x + if abs(x - self.start_pos*self.width) < 15: self.dragging = 'start' + elif abs(x - self.end_pos*self.width) < 15: self.dragging = 'end' + else: self.dragging = 'start' if abs(x - self.start_pos*self.width) < abs(x - self.end_pos*self.width) else 'end' + self.update_pos(x, self.dragging) + + def on_drag(self, event): + if self.dragging: self.update_pos(event.x, self.dragging) + + def on_release(self, event): self.dragging = None + + def update_pos(self, x, handle): + if self.width <= 0: + return + ratio = max(0, min(x, self.width)) / self.width + if handle == 'start': self.start_pos = min(ratio, self.end_pos - 0.001) + elif handle == 'end': self.end_pos = max(ratio, self.start_pos + 0.001) + self.draw() + if self.command: self.command(self.start_pos, self.end_pos, handle) + + def set_values(self, start, end): + self.start_pos, self.end_pos = max(0.0, min(1.0, start)), max(0.0, min(1.0, end)) + self.draw() + +class CTkToolTip: + def __init__(self, widget, message): + self.widget = widget + self.message = message + self.tooltip_window = None + self.widget.bind("", self.show) + self.widget.bind("", self.hide) + + def show(self, event=None): + if self.tooltip_window or not self.message: return + try: + x = self.widget.winfo_rootx() + 25 + y = self.widget.winfo_rooty() + self.widget.winfo_height() + 5 + self.tooltip_window = tk.Toplevel(self.widget) + self.tooltip_window.wm_overrideredirect(True) + self.tooltip_window.wm_geometry(f"+{x}+{y}") + tk.Label(self.tooltip_window, text=self.message, justify='left', background="#2b2b2b", fg="white", relief='solid', borderwidth=1, font=("Segoe UI", 9)).pack(ipadx=3, ipady=3) + except: pass + + def hide(self, event=None): + if self.tooltip_window: + self.tooltip_window.destroy() + self.tooltip_window = None + +class VODGroupRow(ctk.CTkFrame): + def __init__(self, parent, title, date_str, cancel_command=None, theme_colors=None): + bg_card = theme_colors["bg_card"] if theme_colors else "#212121" + b_width = theme_colors.get("border_width", 0) if theme_colors else 0 + b_color = theme_colors.get("border_color", bg_card) if theme_colors else bg_card + + super().__init__(parent, fg_color="transparent") + self.pack(fill="x", pady=4) + self.expanded = False + + # Header Frame mit Border + self.header_frame = ctk.CTkFrame(self, fg_color=bg_card, height=45, corner_radius=6, border_width=b_width, border_color=b_color) + self.header_frame.pack(fill="x") + self.header_frame.grid_columnconfigure(1, weight=1) + + self.lbl_date = ctk.CTkLabel(self.header_frame, text=date_str, text_color="#E5A00D", font=ctk.CTkFont(size=13, weight="bold")) + self.lbl_date.grid(row=0, column=0, padx=(15, 10), pady=10) + + disp_title = title if len(title) < 40 else title[:40] + "..." + self.lbl_title = ctk.CTkLabel(self.header_frame, text=disp_title, anchor="w", font=ctk.CTkFont(size=13)) + self.lbl_title.grid(row=0, column=1, sticky="ew", pady=10) + CTkToolTip(self.lbl_title, title) + + self.actions_frame = ctk.CTkFrame(self.header_frame, fg_color="transparent") + self.actions_frame.grid(row=0, column=2, padx=5, pady=5) + + self.btn_toggle = ctk.CTkButton(self.actions_frame, text="▼", width=35, height=30, fg_color="#333333", hover_color="#444444", font=ctk.CTkFont(size=12), command=self.toggle) + self.btn_toggle.pack(side="left", padx=(0, 5)) + + if cancel_command: + self.btn_stop = ctk.CTkButton(self.actions_frame, text="⏹", width=35, height=30, fg_color="#C0392B", hover_color="#922B21", command=cancel_command) + self.btn_stop.pack(side="left") + CTkToolTip(self.btn_stop, "Diesen Download abbrechen") + + self.content_frame = ctk.CTkFrame(self, fg_color="transparent") + + def toggle(self): + if self.expanded: + self.content_frame.pack_forget() + self.btn_toggle.configure(text="▼") + self.expanded = False + else: + self.content_frame.pack(fill="x", padx=10, pady=(5, 5)) + self.btn_toggle.configure(text="▲") + self.expanded = True + + def show_remove_button(self): + if hasattr(self, 'btn_stop'): self.btn_stop.destroy() + self.btn_remove = ctk.CTkButton(self.actions_frame, text="✖", width=35, height=30, fg_color="#C0392B", hover_color="#922B21", command=self.destroy) + self.btn_remove.pack(side="left") + CTkToolTip(self.btn_remove, "Eintrag entfernen") + +# ========================================== +# MAIN APP CLASS +# ========================================== +class TwitchDownloaderApp(ctk.CTk): + def __init__(self): + super().__init__() + self.config = self.load_config() + self.geometry("1920x1080") + self.minsize(1280, 720) + + current_streamer = self.config.get('streamer_name', '') + self.title(f"Twitch VOD Manager [{APP_VERSION}]" + (f" - {current_streamer}" if current_streamer else "")) + self.protocol("WM_DELETE_WINDOW", self.on_closing) + + # Variablen + self.token = None + self.download_queue = [] + self.is_downloading = False + self.current_process = None + self.current_download_cancelled = False + self.active_tab = "search" + + # Performance: Connection Pooling & Thread Pool + self.session = requests.Session() + adapter = requests.adapters.HTTPAdapter(pool_connections=5, pool_maxsize=10, max_retries=3) + self.session.mount('https://', adapter) + self.thumbnail_executor = ThreadPoolExecutor(max_workers=MAX_THUMBNAIL_WORKERS, thread_name_prefix="thumb_") + + # Theme Cache + self._cached_theme: Optional[Dict[str, Any]] = None + self._cached_theme_name: str = "" + + # Cutter + self.video_cap = None + self.video_total_frames = 0 + self.video_fps = 0 + self.cut_start_sec = 0 + self.cut_end_sec = 0 + self.last_cut_folder = "" + + # UI SETUP + self.grid_columnconfigure(1, weight=1) + self.grid_rowconfigure(0, weight=1) + + # Sidebar + self.frame_left = ctk.CTkFrame(self, width=320, corner_radius=0) + self.frame_left.grid(row=0, column=0, sticky="nsew") + self.frame_left.grid_rowconfigure(5, weight=1) # Queue Container + self.frame_left.grid_rowconfigure(6, weight=2) # Downloads Container (größer) + + self.lbl_logo = ctk.CTkLabel(self.frame_left, text=self._get_greeting(), font=ctk.CTkFont(size=22, weight="bold")) + self.lbl_logo.grid(row=0, column=0, padx=20, pady=(30, 20)) + + self.btn_tab_search = ctk.CTkButton(self.frame_left, text=" 📺 Twitch VODs", height=40, font=ctk.CTkFont(size=14), anchor="w", border_width=0, command=lambda: self.show_frame("search")) + self.btn_tab_search.grid(row=1, column=0, padx=20, pady=10, sticky="ew") + + self.btn_tab_clips = ctk.CTkButton(self.frame_left, text=" 🎬 Twitch Clips", height=40, font=ctk.CTkFont(size=14), anchor="w", border_width=0, command=lambda: self.show_frame("clips")) + self.btn_tab_clips.grid(row=2, column=0, padx=20, pady=(0, 10), sticky="ew") + + self.btn_tab_cutter = ctk.CTkButton(self.frame_left, text=" ✂ Cutter / Splitter", height=40, font=ctk.CTkFont(size=14), anchor="w", border_width=0, command=lambda: self.show_frame("cutter")) + self.btn_tab_cutter.grid(row=3, column=0, padx=20, pady=(0, 10), sticky="ew") + + self.btn_tab_settings = ctk.CTkButton(self.frame_left, text=" ⚙️ Einstellungen", height=40, font=ctk.CTkFont(size=14), anchor="w", border_width=0, command=lambda: self.show_frame("settings")) + self.btn_tab_settings.grid(row=4, column=0, padx=20, pady=(0, 30), sticky="ew") + + # Warteschlange Container mit Border + self.frame_queue_container = ctk.CTkFrame(self.frame_left, border_width=1) + self.frame_queue_container.grid(row=5, column=0, padx=10, pady=(10, 5), sticky="nsew") + self.lbl_queue_title = ctk.CTkLabel(self.frame_queue_container, text="Warteschlange (0):", anchor="w", font=ctk.CTkFont(weight="bold")) + self.lbl_queue_title.pack(padx=10, pady=(8, 0), anchor="w") + self.scroll_queue = ctk.CTkScrollableFrame(self.frame_queue_container, height=120, fg_color="transparent") + self.scroll_queue.pack(padx=5, pady=5, fill="both", expand=True) + + # Aktive Downloads Container mit Border + self.frame_downloads_container = ctk.CTkFrame(self.frame_left, border_width=1) + self.frame_downloads_container.grid(row=6, column=0, padx=10, pady=5, sticky="nsew") + self.lbl_downloads_title = ctk.CTkLabel(self.frame_downloads_container, text="Aktive Downloads & Status:", anchor="w", font=ctk.CTkFont(weight="bold")) + self.lbl_downloads_title.pack(padx=10, pady=(8, 0), anchor="w") + self.scroll_downloads = ctk.CTkScrollableFrame(self.frame_downloads_container, height=220, fg_color="transparent") + self.scroll_downloads.pack(padx=5, pady=5, fill="both", expand=True) + + self.frame_actions = ctk.CTkFrame(self.frame_left, fg_color="transparent") + self.frame_actions.grid(row=7, column=0, padx=20, pady=20, sticky="ew") + + self.btn_start = ctk.CTkButton(self.frame_actions, text="▶ Start", fg_color="green", hover_color="darkgreen", height=40, font=ctk.CTkFont(size=14, weight="bold"), command=self.start_download_thread) + self.btn_start.pack(side="left", fill="x", expand=True, padx=(0, 5)) + + self.btn_clear = ctk.CTkButton(self.frame_actions, text="🗑 Leeren", fg_color="gray30", hover_color="gray40", height=40, command=self.clear_finished_downloads) + self.btn_clear.pack(side="right", fill="x", expand=True, padx=(5, 0)) + + # Main Area + self.frame_right = ctk.CTkFrame(self, corner_radius=0, fg_color="transparent") + self.frame_right.grid(row=0, column=1, sticky="nsew") + self.frame_right.grid_columnconfigure(0, weight=1) + self.frame_right.grid_rowconfigure(0, weight=1) + self.frame_right.grid_rowconfigure(1, weight=0) + + self.frame_content = ctk.CTkFrame(self.frame_right, fg_color="transparent") + self.frame_content.grid(row=0, column=0, sticky="nsew", padx=20, pady=20) + self.frame_content.grid_columnconfigure(0, weight=1) + self.frame_content.grid_rowconfigure(0, weight=1) + + self.frame_log = ctk.CTkFrame(self.frame_right, height=150, corner_radius=10) + self.frame_log.grid(row=1, column=0, sticky="ew", padx=20, pady=(0, 20)) + + ctk.CTkLabel(self.frame_log, text="System Protokoll", font=ctk.CTkFont(size=12, weight="bold")).pack(anchor="w", padx=10, pady=(5,0)) + self.textbox_log = ctk.CTkTextbox(self.frame_log, height=120, font=("Consolas", 11)) + self.textbox_log.pack(fill="both", padx=10, pady=5) + self.textbox_log.configure(state="disabled") + + self.frames = {} + self.frames["search"] = self.create_search_frame(self.frame_content) + self.frames["settings"] = self.create_settings_frame(self.frame_content) + self.frames["cutter"] = self.create_cutter_frame(self.frame_content) + self.frames["clips"] = self.create_clips_frame(self.frame_content) + + # Clip Counter für Dateinamen + self.clip_counter = 1 + + # INIT THEME + self.apply_theme(self.config.get("theme", "Default")) + self.show_frame("search") + + if self.config["client_id"] and self.config["client_secret"]: + threading.Thread(target=self.perform_login, daemon=True).start() + else: + self.show_frame("settings") + + # Gespeicherte Queue wiederherstellen + self.after(100, self.load_queue_state) + + # Update-Check beim Start + self.after(2000, self.check_for_updates_on_startup) + + def on_closing(self) -> None: + if self.is_downloading: + if not messagebox.askokcancel("Beenden?", "Download läuft! Wirklich abbrechen?"): + return + + # Queue speichern falls gewünscht + self.save_queue_state() + self.save_settings(silent=True) + + # Ressourcen freigeben + if self.video_cap: + self.video_cap.release() + + if self.current_process: + try: + self.current_process.kill() + except (OSError, ProcessLookupError): + pass + + # Thread Pool herunterfahren + if hasattr(self, 'thumbnail_executor'): + self.thumbnail_executor.shutdown(wait=False) + + # Session schließen + if hasattr(self, 'session'): + self.session.close() + + self.destroy() + sys.exit() + + def apply_theme(self, theme_name): + if theme_name not in THEMES: + theme_name = "Default" + self.current_theme_name = theme_name + colors = THEMES[theme_name] + self.config["theme"] = theme_name + + # UI Style Parameter + corner_radius = colors.get("corner_radius", 8) + btn_radius = colors.get("button_corner_radius", 8) + font_family = colors.get("font_family", "Segoe UI") + font_title = colors.get("font_size_title", 22) + font_normal = colors.get("font_size_normal", 14) + font_small = colors.get("font_size_small", 12) + + # Hintergrund-Farben + self.configure(fg_color=colors["bg_main"]) + self.frame_left.configure(fg_color=colors["bg_sidebar"]) + btn_border_width = colors.get("button_border_width", 0) + btn_border_color = colors.get("button_border_color", "transparent") + + # Queue & Downloads Container mit Border + self.frame_queue_container.configure(fg_color=colors["bg_card"], corner_radius=corner_radius, border_width=btn_border_width, border_color=btn_border_color) + self.frame_downloads_container.configure(fg_color=colors["bg_card"], corner_radius=corner_radius, border_width=btn_border_width, border_color=btn_border_color) + self.lbl_downloads_title.configure(text_color=colors["text"], font=ctk.CTkFont(family=font_family, size=font_small, weight="bold")) + self.frame_right.configure(fg_color=colors["bg_main"]) + self.frame_log.configure(fg_color=colors["bg_card"], corner_radius=corner_radius) + self.textbox_log.configure(fg_color=colors["bg_main"], text_color=colors["text"], corner_radius=corner_radius) + + # Logo + self.lbl_logo.configure(text_color=colors["text"], font=ctk.CTkFont(family=font_family, size=font_title, weight="bold")) + + # Tab Buttons Style + self.update_tab_buttons(colors) + + # Start/Clear Buttons + self.btn_start.configure(corner_radius=btn_radius, font=ctk.CTkFont(family=font_family, size=font_normal, weight="bold")) + self.btn_clear.configure(corner_radius=btn_radius, font=ctk.CTkFont(family=font_family, size=font_normal)) + + # Queue Title + self.lbl_queue_title.configure(text_color=colors["text"], font=ctk.CTkFont(family=font_family, size=font_small, weight="bold")) + + def update_tab_buttons(self, colors): + btn_radius = colors.get("button_corner_radius", 8) + font_family = colors.get("font_family", "Segoe UI") + font_normal = colors.get("font_size_normal", 14) + btn_border_width = colors.get("button_border_width", 0) + btn_border_color = colors.get("button_border_color", "transparent") + + for btn in [self.btn_tab_search, self.btn_tab_cutter, self.btn_tab_clips, self.btn_tab_settings]: + btn.configure( + fg_color="transparent", + text_color=colors["text"], + hover_color=colors["bg_card"], + corner_radius=btn_radius, + border_width=btn_border_width, + border_color=btn_border_color, + font=ctk.CTkFont(family=font_family, size=font_normal) + ) + active_btn = None + if self.active_tab == "search": active_btn = self.btn_tab_search + elif self.active_tab == "cutter": active_btn = self.btn_tab_cutter + elif self.active_tab == "clips": active_btn = self.btn_tab_clips + elif self.active_tab == "settings": active_btn = self.btn_tab_settings + if active_btn: + active_btn.configure(fg_color=colors["accent"], text_color=colors["tab_text_active"], hover_color=colors["accent_hover"]) + + def show_frame(self, name: str) -> None: + self.active_tab = name + for frame in self.frames.values(): + frame.grid_forget() + self.frames[name].grid(row=0, column=0, sticky="nsew") + self.update_tab_buttons(THEMES[self.current_theme_name]) + + # ========================================== + # HELPER METHODS + # ========================================== + def get_theme_colors(self) -> Dict[str, Any]: + """Cached Theme-Colors abrufen.""" + if self._cached_theme is None or self._cached_theme_name != self.current_theme_name: + self._cached_theme = THEMES[self.current_theme_name].copy() + self._cached_theme_name = self.current_theme_name + return self._cached_theme + + def get_themed_frame_config(self) -> Dict[str, Any]: + """Frame-Konfiguration mit Theme-Borders.""" + colors = self.get_theme_colors() + b_width = colors.get("border_width", 0) + b_color = colors.get("border_color", colors["bg_card"]) + if b_color == "transparent" and b_width > 0: + b_color = "#333333" + return {"fg_color": colors["bg_card"], "border_width": b_width, "border_color": b_color} + + def format_eta(self, bytes_remaining: float, speed_bps: float) -> str: + """Geschaetzte Restzeit formatieren.""" + if speed_bps <= 0: + return "berechne..." + eta_sec = bytes_remaining / speed_bps + return self.format_seconds(eta_sec) + + # ========================================== + # QUEUE PERSISTENCE + # ========================================== + def save_queue_state(self) -> None: + """Queue-Status in Datei speichern.""" + queue_data = [] + for item in self.download_queue: + if item.get('is_merge_job'): + continue # Merge-Jobs nicht speichern + try: + queue_data.append({ + 'title': item['title'], + 'url': item['url'], + 'date': item['date'].isoformat() if hasattr(item['date'], 'isoformat') else str(item['date']), + 'streamer': item['streamer'], + 'duration_str': item['duration_str'], + 'custom_clip': item.get('custom_clip') + }) + except (KeyError, AttributeError): + continue + try: + with open(QUEUE_FILE, 'w', encoding='utf-8') as f: + json.dump(queue_data, f, indent=2) + except IOError as e: + self.log(f"Queue speichern fehlgeschlagen: {e}") + + def load_queue_state(self) -> None: + """Queue-Status aus Datei laden.""" + if not os.path.exists(QUEUE_FILE): + return + try: + with open(QUEUE_FILE, 'r', encoding='utf-8') as f: + queue_data = json.load(f) + for item in queue_data: + try: + dt = datetime.datetime.fromisoformat(item['date']) + self.add_to_queue( + item['title'], + item['url'], + dt, + item['streamer'], + item['duration_str'], + item.get('custom_clip') + ) + except (KeyError, ValueError) as e: + self.log(f"Queue-Item konnte nicht geladen werden: {e}") + # Datei nach erfolgreichem Laden löschen + try: + os.remove(QUEUE_FILE) + except OSError: + pass # Ignorieren wenn Löschen fehlschlägt + if queue_data: + self.log(f"{len(queue_data)} Queue-Items wiederhergestellt.") + except (IOError, json.JSONDecodeError) as e: + self.log(f"Queue laden fehlgeschlagen: {e}") + + # ========================================== + # RETRY MECHANISM + # ========================================== + def run_streamlink_with_retry(self, cmd: List[str], filename: str, label: str, + duration_sec: int, parent_group, attempt: int = 1) -> bool: + """Streamlink mit Retry-Mechanismus ausfuehren.""" + success = self.run_streamlink_process_with_cmd(cmd, filename, label, duration_sec, parent_group) + if success: + return True + + if attempt < MAX_RETRY_ATTEMPTS and not self.current_download_cancelled: + delay = RETRY_DELAY_SECONDS * attempt + self.log(f"Retry {attempt}/{MAX_RETRY_ATTEMPTS} für {label} in {delay}s...") + time.sleep(delay) + return self.run_streamlink_with_retry(cmd, filename, label, duration_sec, parent_group, attempt + 1) + + return False + + def create_settings_frame(self, parent): + frame = ctk.CTkScrollableFrame(parent, fg_color="transparent") + card_theme = ctk.CTkFrame(frame) + card_theme.pack(fill="x", pady=10, padx=10) + ctk.CTkLabel(card_theme, text="🎨 Design & Theme", font=("Arial", 16, "bold")).pack(anchor="w", padx=15, pady=(15, 5)) + self.var_theme = ctk.StringVar(value=self.config.get("theme", "Default")) + self.opt_theme = ctk.CTkOptionMenu(card_theme, values=list(THEMES.keys()), variable=self.var_theme, command=self.apply_theme) + self.opt_theme.pack(anchor="w", padx=15, pady=(0, 15)) + + card_api = ctk.CTkFrame(frame) + card_api.pack(fill="x", pady=10, padx=10) + ctk.CTkLabel(card_api, text="🔑 API Zugang", font=("Arial", 16, "bold")).pack(anchor="w", padx=15, pady=(15, 5)) + ctk.CTkLabel(card_api, text="Client ID:").pack(anchor="w", padx=15) + self.entry_client_id = ctk.CTkEntry(card_api) + self.entry_client_id.insert(0, self.config["client_id"]) + self.entry_client_id.pack(fill="x", padx=15, pady=(0, 10)) + self.entry_client_id.bind("", lambda e: self.save_settings()) + ctk.CTkLabel(card_api, text="Client Secret:").pack(anchor="w", padx=15) + self.entry_client_secret = ctk.CTkEntry(card_api, show="*") + self.entry_client_secret.insert(0, self.config["client_secret"]) + self.entry_client_secret.pack(fill="x", padx=15, pady=(0, 15)) + self.entry_client_secret.bind("", lambda e: self.save_settings()) + + card_storage = ctk.CTkFrame(frame) + card_storage.pack(fill="x", pady=10, padx=10) + ctk.CTkLabel(card_storage, text="📁 Speicherort", font=("Arial", 16, "bold")).pack(anchor="w", padx=15, pady=(15, 5)) + path_box = ctk.CTkFrame(card_storage, fg_color="transparent") + path_box.pack(fill="x", padx=15, pady=(0, 15)) + self.entry_path = ctk.CTkEntry(path_box) + self.entry_path.insert(0, self.config["download_path"]) + self.entry_path.pack(side="left", fill="x", expand=True, padx=(0, 10)) + self.entry_path.bind("", lambda e: self.save_settings()) + ctk.CTkButton(path_box, text="📂", width=45, command=self.browse_folder).pack(side="right", padx=(5, 0)) + ctk.CTkButton(path_box, text="↗", width=45, fg_color="#34495E", hover_color="#2E4053", command=self.open_save_folder).pack(side="right") + + card_yt = ctk.CTkFrame(frame) + card_yt.pack(fill="x", pady=10, padx=10) + yt_header = ctk.CTkFrame(card_yt, fg_color="transparent") + yt_header.pack(anchor="w", padx=15, pady=(15, 5)) + ctk.CTkLabel(yt_header, text="📺 YouTube Auto-Upload", font=("Arial", 16, "bold")).pack(side="left") + ctk.CTkLabel(yt_header, text="(Coming Soon)", font=("Arial", 12), text_color="gray").pack(side="left", padx=(10, 0)) + + self.chk_upload_val = ctk.BooleanVar(value=False) + self.chk_upload = ctk.CTkCheckBox(card_yt, text="Automatisch hochladen", variable=self.chk_upload_val, state="disabled", text_color_disabled="gray") + self.chk_upload.pack(anchor="w", padx=15, pady=(0, 10)) + ctk.CTkLabel(card_yt, text="Firefox Profil Pfad:", text_color="gray").pack(anchor="w", padx=15) + self.entry_profile_path = ctk.CTkEntry(card_yt, state="disabled", fg_color="gray25") + self.entry_profile_path.pack(fill="x", padx=15, pady=(0, 15)) + + card_opts = ctk.CTkFrame(frame) + card_opts.pack(fill="x", pady=10, padx=10) + ctk.CTkLabel(card_opts, text="⚙️ Download Optionen", font=("Arial", 16, "bold")).pack(anchor="w", padx=15, pady=(15, 5)) + self.seg_mode = ctk.CTkSegmentedButton(card_opts, values=["Parts (Gesplittet)", "Full (Ganzes VOD)"], command=lambda e: self.save_settings()) + self.seg_mode.set("Parts (Gesplittet)" if self.config.get("download_mode") == "parts" else "Full (Ganzes VOD)") + self.seg_mode.pack(fill="x", padx=15, pady=(0, 10)) + ctk.CTkLabel(card_opts, text="Part Länge (Minuten):").pack(anchor="w", padx=15) + minutes_frame = ctk.CTkFrame(card_opts, fg_color="transparent") + minutes_frame.pack(anchor="w", padx=15, pady=(0, 15)) + self.entry_minutes = ctk.CTkEntry(minutes_frame, width=100) + self.entry_minutes.insert(0, str(self.config.get("part_minutes", 120))) + self.entry_minutes.pack(side="left") + ctk.CTkButton(minutes_frame, text="Speichern", width=80, command=self.save_part_minutes).pack(side="left", padx=(10, 0)) + + # Update Card + card_update = ctk.CTkFrame(frame) + card_update.pack(fill="x", pady=10, padx=10) + ctk.CTkLabel(card_update, text="🔄 Updates", font=("Arial", 16, "bold")).pack(anchor="w", padx=15, pady=(15, 5)) + update_frame = ctk.CTkFrame(card_update, fg_color="transparent") + update_frame.pack(fill="x", padx=15, pady=(0, 15)) + self.lbl_version = ctk.CTkLabel(update_frame, text=f"Aktuelle Version: {APP_VERSION}") + self.lbl_version.pack(anchor="w") + self.lbl_update_status = ctk.CTkLabel(update_frame, text="", text_color="gray") + self.lbl_update_status.pack(anchor="w", pady=(5, 0)) + btn_frame = ctk.CTkFrame(update_frame, fg_color="transparent") + btn_frame.pack(anchor="w", pady=(10, 0)) + self.btn_check_update = ctk.CTkButton(btn_frame, text="Nach Updates suchen", width=160, command=self.check_for_updates) + self.btn_check_update.pack(side="left") + self.btn_download_update = ctk.CTkButton(btn_frame, text="Update installieren", width=140, fg_color="green", hover_color="darkgreen", command=self.download_and_install_update, state="disabled") + self.btn_download_update.pack(side="left", padx=(10, 0)) + + return frame + + def create_cutter_frame(self, parent): + frame = ctk.CTkFrame(parent, fg_color="transparent") + content = ctk.CTkFrame(frame, fg_color="transparent") + content.pack(fill="both", expand=True, padx=20, pady=20) + ctk.CTkLabel(content, text="Video Cutter (Timeline)", font=ctk.CTkFont(size=22, weight="bold")).pack(pady=10) + file_frame = ctk.CTkFrame(content, fg_color="transparent") + file_frame.pack(fill="x", padx=20, pady=5) + self.entry_cut_file = ctk.CTkEntry(file_frame, placeholder_text="Pfad zur .mp4 Datei") + self.entry_cut_file.pack(side="left", fill="x", expand=True, padx=(0, 10)) + ctk.CTkButton(file_frame, text="📂", width=50, command=self.browse_video_file).pack(side="right") + self.lbl_file_info = ctk.CTkLabel(content, text="", text_color="gray") + self.lbl_file_info.pack(pady=(0, 10)) + self.editor_area = ctk.CTkFrame(content, fg_color="gray15") + self.editor_area.pack(fill="both", expand=True, padx=20, pady=10) + self.lbl_preview = ctk.CTkLabel(self.editor_area, text="[Vorschau]", width=480, height=270, fg_color="black") + self.lbl_preview.pack(pady=10) + self.timeline = VideoTimeline(self.editor_area, width=600, height=40, bg="#212121", command=self.on_timeline_change) + self.timeline.pack(fill="x", padx=20, pady=10) + ctrl_frame = ctk.CTkFrame(self.editor_area, fg_color="transparent") + ctrl_frame.pack(fill="x", pady=10) + box_in = ctk.CTkFrame(ctrl_frame, fg_color="transparent") + box_in.pack(side="left", padx=40) + ctk.CTkLabel(box_in, text="Start:", font=("Arial", 12, "bold"), text_color="gray").pack() + self.entry_time_in = ctk.CTkEntry(box_in, width=100, font=("Consolas", 14), justify="center") + self.entry_time_in.pack() + self.entry_time_in.bind("", lambda e: self.manual_time_update()) + box_out = ctk.CTkFrame(ctrl_frame, fg_color="transparent") + box_out.pack(side="right", padx=40) + ctk.CTkLabel(box_out, text="Ende:", font=("Arial", 12, "bold"), text_color="gray").pack() + self.entry_time_out = ctk.CTkEntry(box_out, width=100, font=("Consolas", 14), justify="center") + self.entry_time_out.pack() + self.entry_time_out.bind("", lambda e: self.manual_time_update()) + self.btn_cut_action = ctk.CTkButton(content, text="✂ Ausschnitt erstellen", height=45, font=ctk.CTkFont(size=16), fg_color="green", hover_color="darkgreen", command=self.start_cut_thread) + self.btn_cut_action.pack(pady=(15, 5)) + self.btn_open_cut_folder = ctk.CTkButton(content, text="📂 Ordner öffnen", width=150, fg_color="#34495E", hover_color="#2E4053", command=self.open_cut_folder) + self.progress_cut = ctk.CTkProgressBar(content, height=12, width=400) + self.progress_cut.set(0) + self.progress_cut.pack(pady=(5, 5)) + self.progress_cut.pack_forget() + self.lbl_cut_status = ctk.CTkLabel(content, text="", font=ctk.CTkFont(size=14)) + self.lbl_cut_status.pack(pady=(5, 15)) + return frame + + def create_clips_frame(self, parent): + frame = ctk.CTkFrame(parent, fg_color="transparent") + content = ctk.CTkFrame(frame, fg_color="transparent") + content.pack(fill="both", expand=True, padx=20, pady=20) + + ctk.CTkLabel(content, text="Twitch Clip Downloader", font=ctk.CTkFont(size=22, weight="bold")).pack(pady=(10, 20)) + + # URL Eingabe + url_frame = ctk.CTkFrame(content, fg_color="transparent") + url_frame.pack(fill="x", padx=20, pady=10) + ctk.CTkLabel(url_frame, text="Clip URL:", font=ctk.CTkFont(size=14)).pack(anchor="w") + self.entry_clip_url = ctk.CTkEntry(url_frame, placeholder_text="https://clips.twitch.tv/... oder https://www.twitch.tv/.../clip/...", height=40) + self.entry_clip_url.pack(fill="x", pady=(5, 10)) + + # Download Button + self.btn_download_clip = ctk.CTkButton( + content, text="⬇ Clip herunterladen", height=40, + font=ctk.CTkFont(family="Segoe UI", size=14, weight="bold"), fg_color="green", hover_color="darkgreen", + command=self.download_clip + ) + self.btn_download_clip.pack(pady=15) + + # Status + self.lbl_clip_status = ctk.CTkLabel(content, text="", font=ctk.CTkFont(size=14)) + self.lbl_clip_status.pack(pady=10) + + # Progress + self.progress_clip = ctk.CTkProgressBar(content, height=12, width=400) + self.progress_clip.set(0) + self.progress_clip.pack(pady=5) + self.progress_clip.pack_forget() + + # Info Box + info_frame = ctk.CTkFrame(content) + info_frame.pack(fill="x", padx=20, pady=20) + ctk.CTkLabel(info_frame, text="ℹ️ Info", font=ctk.CTkFont(size=14, weight="bold")).pack(anchor="w", padx=15, pady=(10, 5)) + ctk.CTkLabel( + info_frame, + text="Unterstützte Formate:\n• https://clips.twitch.tv/ClipName\n• https://www.twitch.tv/streamer/clip/ClipName\n\nDateien werden gespeichert als:\nStreamer_Datum_Clip_0001_ClipName.mp4", + justify="left", text_color="gray" + ).pack(anchor="w", padx=15, pady=(0, 15)) + + return frame + + def download_clip(self) -> None: + """Twitch Clip herunterladen.""" + url = self.entry_clip_url.get().strip() + if not url: + self.lbl_clip_status.configure(text="Bitte Clip-URL eingeben!", text_color="red") + return + + if not self.token: + self.lbl_clip_status.configure(text="Nicht eingeloggt! Bitte API-Daten in Einstellungen eingeben.", text_color="red") + return + + # UI aktualisieren + self.btn_download_clip.configure(state="disabled", text="Lade...") + self.lbl_clip_status.configure(text="Hole Clip-Informationen...", text_color="yellow") + self.progress_clip.pack(pady=5) + self.progress_clip.set(0) + + threading.Thread(target=self._download_clip_thread, args=(url,), daemon=True).start() + + def _download_clip_thread(self, url: str) -> None: + """Clip-Download in separatem Thread.""" + try: + # Clip-ID aus URL extrahieren + clip_id = self._extract_clip_id(url) + if not clip_id: + self.after(0, lambda: self.lbl_clip_status.configure(text="Ungültige Clip-URL!", text_color="red")) + self.after(0, lambda: self.btn_download_clip.configure(state="normal", text="⬇ Clip herunterladen")) + return + + # Clip-Info von API holen + headers = {'Client-ID': self.config["client_id"], 'Authorization': f'Bearer {self.token}'} + resp = self.session.get( + 'https://api.twitch.tv/helix/clips', + params={'id': clip_id}, + headers=headers, + timeout=API_TIMEOUT + ) + + try: + json_data = resp.json() + except json.JSONDecodeError: + self.after(0, lambda: self.lbl_clip_status.configure(text="Ungültige API-Antwort!", text_color="red")) + self.after(0, lambda: self.btn_download_clip.configure(state="normal", text="⬇ Clip herunterladen")) + return + + if resp.status_code != 200 or not json_data.get('data'): + self.after(0, lambda: self.lbl_clip_status.configure(text="Clip nicht gefunden!", text_color="red")) + self.after(0, lambda: self.btn_download_clip.configure(state="normal", text="⬇ Clip herunterladen")) + return + + clip_data = json_data['data'][0] + broadcaster_name = clip_data['broadcaster_name'] + clip_title = clip_data['title'] + created_at = clip_data['created_at'] + thumbnail_url = clip_data['thumbnail_url'] + + # Datum formatieren + try: + dt = datetime.datetime.strptime(created_at, "%Y-%m-%dT%H:%M:%SZ") + date_str = dt.strftime("%Y-%m-%d") + except: + date_str = datetime.datetime.now().strftime("%Y-%m-%d") + + # Clip-Titel bereinigen (keine Sonderzeichen im Dateinamen) + safe_title = "".join(c for c in clip_title if c.isalnum() or c in " -_").strip()[:50] + + # Dateiname erstellen + filename = f"{broadcaster_name}_{date_str}_Clip_{self.clip_counter:04d}_{safe_title}.mp4" + self.clip_counter += 1 + + # Speicherpfad - Twitch_Clips Ordner im Verzeichnis der Python-Datei + script_dir = os.path.dirname(os.path.abspath(__file__)) + save_path = os.path.join(script_dir, "Twitch_Clips", broadcaster_name) + os.makedirs(save_path, exist_ok=True) + filepath = os.path.join(save_path, filename) + + self.after(0, lambda ct=clip_title: self.lbl_clip_status.configure(text=f"Lade: {ct[:40]}...", text_color="yellow")) + + # Clip-URL für Streamlink erstellen + clip_url = f"https://clips.twitch.tv/{clip_id}" + + # Streamlink für Download verwenden + cmd = [ + *get_streamlink_cmd(), + clip_url, + "best", + "-o", filepath, + "--force" + ] + + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + creationflags=CREATE_NO_WINDOW if os.name == 'nt' else 0 + ) + self.current_process = process # Speichern für Cleanup bei App-Close + + try: + stdout, stderr = process.communicate(timeout=120) + except subprocess.TimeoutExpired: + process.kill() + process.communicate() # Clean up + self.after(0, lambda: self.lbl_clip_status.configure(text="Timeout beim Download!", text_color="red")) + self.log("Clip-Download Timeout (120s)") + return + + if process.returncode != 0: + error_msg = stderr.decode('utf-8', errors='ignore') if stderr else "Unbekannter Fehler" + self.after(0, lambda: self.lbl_clip_status.configure(text="Download fehlgeschlagen!", text_color="red")) + self.log(f"Streamlink Fehler: {error_msg}") + self.after(0, lambda: self.progress_clip.pack_forget()) + return + + # Prüfen ob Datei existiert und größer als 1KB ist + if os.path.exists(filepath) and os.path.getsize(filepath) > 1024: + self.after(0, lambda: self.progress_clip.set(1.0)) + self.after(0, lambda fn=filename: self.lbl_clip_status.configure(text=f"Gespeichert: {fn}", text_color="green")) + self.log(f"Clip heruntergeladen: {filename}") + else: + self.after(0, lambda: self.lbl_clip_status.configure(text="Download fehlgeschlagen!", text_color="red")) + if os.path.exists(filepath): + os.remove(filepath) + + except Exception as e: + self.after(0, lambda err=e: self.lbl_clip_status.configure(text=f"Fehler: {err}", text_color="red")) + self.log(f"Clip-Download Fehler: {e}") + + finally: + self.after(0, lambda: self.btn_download_clip.configure(state="normal", text="⬇ Clip herunterladen")) + self.after(0, lambda: self.progress_clip.pack_forget()) + + def _get_greeting(self) -> str: + """Zeitabhängige Begrüßung zurückgeben.""" + hour = datetime.datetime.now().hour + if 5 <= hour < 12: + return "Guten Morgen..." + elif 12 <= hour < 18: + return "Guten Tag..." + else: + return "Guten Abend..." + + def _extract_clip_id(self, url: str) -> Optional[str]: + """Clip-ID aus verschiedenen URL-Formaten extrahieren.""" + # Format: https://clips.twitch.tv/ClipName + match = re.search(r'clips\.twitch\.tv/([A-Za-z0-9_-]+)', url) + if match: + return match.group(1) + + # Format: https://www.twitch.tv/streamer/clip/ClipName + match = re.search(r'twitch\.tv/[^/]+/clip/([A-Za-z0-9_-]+)', url) + if match: + return match.group(1) + + return None + + def open_cut_folder(self): + if self.last_cut_folder and os.path.exists(self.last_cut_folder): + try: + os.startfile(self.last_cut_folder) + except: + pass + + def browse_video_file(self): + f = filedialog.askopenfilename(filetypes=[("Video files", "*.mp4 *.mkv *.ts *.mov")]) + if f: + self.entry_cut_file.delete(0, "end") + self.entry_cut_file.insert(0, f) + threading.Thread(target=self.load_video_data, args=(f,), daemon=True).start() + + def load_video_data(self, filepath): + try: + self.after(0, lambda: self.lbl_cut_status.configure(text="Lade Video...", text_color="yellow")) + if self.video_cap: + self.video_cap.release() + try: + self.video_cap = cv2.VideoCapture(filepath) + except: + self.after(0, lambda: self.lbl_cut_status.configure(text="OpenCV Error", text_color="red")) + return + if not self.video_cap.isOpened(): + raise Exception("Datei nicht lesbar") + self.video_total_frames = int(self.video_cap.get(cv2.CAP_PROP_FRAME_COUNT)) + if self.video_total_frames <= 0: + raise Exception("Video-Frames nicht lesbar") + self.video_fps = self.video_cap.get(cv2.CAP_PROP_FPS) + if self.video_fps <= 0: + self.video_fps = 30.0 # Fallback FPS + duration_sec = self.video_total_frames / self.video_fps + self.cut_start_sec = 0 + self.cut_end_sec = duration_sec + self.after(0, lambda: self.timeline.set_values(0.0, 1.0)) + self.after(0, lambda ds=duration_sec: self.lbl_file_info.configure(text=f"Länge: {self.format_seconds(ds)}")) + self.after(0, lambda: self.lbl_cut_status.configure(text="Bereit.", text_color="green")) + self.after(0, lambda: self.update_preview(0)) + self.after(0, lambda: self.update_cut_labels()) + except Exception as e: + self.after(0, lambda err=e: self.lbl_cut_status.configure(text=f"Fehler: {err}", text_color="red")) + + def on_timeline_change(self, start_ratio, end_ratio, handle_moved): + if not self.video_cap or self.video_total_frames == 0 or self.video_fps <= 0: + return + total_sec = self.video_total_frames / self.video_fps + self.cut_start_sec = start_ratio * total_sec + self.cut_end_sec = end_ratio * total_sec + self.update_cut_labels() + target_sec = self.cut_start_sec if handle_moved == 'start' else self.cut_end_sec + self.update_preview(int(target_sec * self.video_fps)) + + def manual_time_update(self): + if not self.video_cap or self.video_total_frames == 0 or self.video_fps <= 0: return + try: + s_parts = list(map(int, self.entry_time_in.get().split(':'))) + if len(s_parts) != 3: raise ValueError("Ungültiges Format") + s_sec = s_parts[0]*3600 + s_parts[1]*60 + s_parts[2] + e_parts = list(map(int, self.entry_time_out.get().split(':'))) + if len(e_parts) != 3: raise ValueError("Ungültiges Format") + e_sec = e_parts[0]*3600 + e_parts[1]*60 + e_parts[2] + total_sec = self.video_total_frames / self.video_fps + s_sec = max(0, min(s_sec, total_sec)) + e_sec = max(0, min(e_sec, total_sec)) + if s_sec >= e_sec: s_sec = e_sec - 1 + self.cut_start_sec = s_sec + self.cut_end_sec = e_sec + self.timeline.set_values(s_sec / total_sec, e_sec / total_sec) + self.update_cut_labels() + self.update_preview(int(s_sec * self.video_fps)) + except: + self.lbl_cut_status.configure(text="Format Fehler (HH:MM:SS)", text_color="red") + + def update_cut_labels(self): + self.entry_time_in.delete(0, "end") + self.entry_time_in.insert(0, self.format_seconds(self.cut_start_sec)) + self.entry_time_out.delete(0, "end") + self.entry_time_out.insert(0, self.format_seconds(self.cut_end_sec)) + + def update_preview(self, frame_no): + if not self.video_cap: return + self.video_cap.set(cv2.CAP_PROP_POS_FRAMES, frame_no) + ret, frame = self.video_cap.read() + if ret: + frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + img = Image.fromarray(frame) + img.thumbnail((480, 270)) + ctk_img = ctk.CTkImage(light_image=img, dark_image=img, size=img.size) + self.lbl_preview.configure(image=ctk_img, text="") + self.video_preview_image = ctk_img + + def format_seconds(self, seconds: float) -> str: + """Sekunden in HH:MM:SS Format umwandeln.""" + m, s = divmod(int(seconds), 60) + h, m = divmod(m, 60) + return f"{h:02d}:{m:02d}:{s:02d}" + + def start_cut_thread(self): + if not self.entry_cut_file.get().strip(): + self.lbl_cut_status.configure(text="Bitte Datei auswählen!", text_color="red") + return + if not os.path.exists(self.entry_cut_file.get()): + self.lbl_cut_status.configure(text="Datei nicht gefunden!", text_color="red") + return + if self.cut_end_sec <= self.cut_start_sec: + self.lbl_cut_status.configure(text="Ende muss nach Start sein!", text_color="red") + return + self.btn_open_cut_folder.pack_forget() + self.btn_cut_action.configure(state="disabled", text="Initialisiere...", fg_color="gray40") + self.progress_cut.pack(pady=(5, 5)) + self.progress_cut.set(0) + self.lbl_cut_status.configure(text="Starte FFmpeg...", text_color="yellow") + start_str = self.format_seconds(self.cut_start_sec) + end_str = self.format_seconds(self.cut_end_sec) + threading.Thread(target=self.run_ffmpeg_cut, args=(self.entry_cut_file.get(), start_str, end_str), daemon=True).start() + + def run_ffmpeg_cut(self, input_file, start, end): + try: + self.last_cut_folder = os.path.dirname(input_file) + out = os.path.join(self.last_cut_folder, f"{os.path.splitext(os.path.basename(input_file))[0]}_cut_{datetime.datetime.now().strftime('%H%M%S')}.mp4") + total_duration_sec = self.cut_end_sec - self.cut_start_sec + if total_duration_sec <= 0: total_duration_sec = 1 + total_duration_us = total_duration_sec * 1_000_000 + cmd = [imageio_ffmpeg.get_ffmpeg_exe(), "-i", input_file, "-ss", start, "-to", end, "-c", "copy", "-progress", "pipe:1", "-y", out] + creation_flags = CREATE_NO_WINDOW if os.name == 'nt' else 0 + process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, creationflags=creation_flags, universal_newlines=True) + self.current_process = process # Speichern für Cleanup bei App-Close + while True: + line = process.stdout.readline() + if not line: + if process.poll() is not None: break + continue + if "out_time_us=" in line: + try: + current_us = int(line.strip().split("=")[1]) + percent = min(1.0, current_us / total_duration_us) + self.after(0, lambda p=percent, d=int(percent*100): (self.progress_cut.set(p), self.btn_cut_action.configure(text=f"Schneide... {d}%"))) + except: pass + if process.poll() == 0: + out_basename = os.path.basename(out) + self.after(0, lambda ob=out_basename: self.lbl_cut_status.configure(text=f"Fertig: {ob}", text_color="green")) + self.after(0, lambda: self.progress_cut.set(1.0)) + self.after(0, lambda: self.btn_open_cut_folder.pack(before=self.lbl_cut_status, pady=5)) + else: + self.after(0, lambda: self.lbl_cut_status.configure(text="Fehler beim Schneiden (FFmpeg)", text_color="red")) + self.after(0, lambda: self.progress_cut.pack_forget()) + except Exception as e: + self.after(0, lambda err=e: self.lbl_cut_status.configure(text=f"Fehler: {err}", text_color="red")) + self.after(0, lambda: self.progress_cut.pack_forget()) + self.after(0, lambda: self.btn_cut_action.configure(state="normal", text="✂ Ausschnitt erstellen", fg_color="green")) + + # ========================================== + # DOWNLOAD & UPLOAD + # ========================================== + def open_merge_dialog(self, source_item): + if len(self.download_queue) < 2: + messagebox.showinfo("Merge Info", "Du brauchst mindestens 2 Videos in der Queue.") + return + + dialog = tk.Toplevel(self) + dialog.title("Merge Auswahl") + dialog.geometry("600x400") + dialog.configure(bg="#2b2b2b") + dialog.grab_set() + + tk.Label(dialog, text=f"Basis (#1): {source_item['title'][:30]}...", fg="#E5A00D", bg="#2b2b2b", font=("Arial", 11, "bold")).pack(pady=10) + tk.Label(dialog, text="Wähle weitere Teile in der gewünschten Reihenfolge:", fg="white", bg="#2b2b2b").pack() + + scroll_frame = ctk.CTkScrollableFrame(dialog, height=200, bg_color="#2b2b2b", fg_color="#212121") + scroll_frame.pack(fill="both", expand=True, padx=10, pady=5) + + possible_items = [x for x in self.download_queue if x['ui_widget'] != source_item['ui_widget'] and 'is_merge_job' not in x] + + selection_order = [] + item_rows = [] + + def update_numbers(): + for idx, (item, lbl) in enumerate(item_rows): + if item in selection_order: + pos = selection_order.index(item) + 2 + lbl.configure(text=f"#{pos}", text_color="#E5A00D") + else: + lbl.configure(text="", text_color="gray") + + def on_toggle(item, var): + if var.get(): + if item not in selection_order: + selection_order.append(item) + else: + if item in selection_order: + selection_order.remove(item) + update_numbers() + + if not possible_items: + ctk.CTkLabel(scroll_frame, text="Keine weiteren Videos verfügbar.", text_color="gray").pack(pady=20) + + for item in possible_items: + row = ctk.CTkFrame(scroll_frame, fg_color="transparent") + row.pack(fill="x", pady=2) + + lbl_num = ctk.CTkLabel(row, text="", width=30, font=("Arial", 12, "bold")) + lbl_num.pack(side="left", padx=5) + item_rows.append((item, lbl_num)) + + var = ctk.BooleanVar() + chk = ctk.CTkCheckBox(row, text=item['title'], variable=var, command=lambda i=item, v=var: on_toggle(i, v)) + chk.pack(side="left", fill="x", expand=True) + + def confirm(mode): + if not selection_order: + messagebox.showwarning("Fehler", "Bitte mindestens ein weiteres Video wählen!") + return + + self.create_merge_job(source_item, selection_order, mode) + dialog.destroy() + + btn_box = tk.Frame(dialog, bg="#2b2b2b") + btn_box.pack(fill="x", pady=15) + + ctk.CTkButton(btn_box, text="Verbinden & Splitten", fg_color="green", width=180, command=lambda: confirm("split")).pack(side="left", padx=10) + ctk.CTkButton(btn_box, text="Nur Verbinden (Full)", fg_color="#D35400", width=180, command=lambda: confirm("full")).pack(side="right", padx=10) + + def create_merge_job(self, source_item, other_items, merge_mode): + all_items = [source_item] + other_items + total_dur = sum([self.parse_duration_string(i['duration_str']) for i in all_items]) + + for item in all_items: + if item.get('ui_widget'): + item['ui_widget'].destroy() + if item in self.download_queue: + self.download_queue.remove(item) + + m, s = divmod(total_dur, 60) + h, m = divmod(m, 60) + dur_str = f"{h}h{m}m{s}s" + combined_title = f"MERGE ({merge_mode.upper()}): {source_item['title'][:15]}... + {len(other_items)} Parts" + + colors = THEMES[self.current_theme_name] + b_width = colors.get("border_width", 0) + b_color = colors.get("border_color", "transparent") + + if b_color == "transparent" and b_width > 0: + b_color = "#333333" + + f = ctk.CTkFrame(self.scroll_queue, fg_color=colors["bg_card"], height=40, border_width=b_width, border_color=b_color) + f.pack(fill="x", pady=2, padx=2) + ctk.CTkButton(f, text="✖", width=30, height=25, fg_color="#C0392B", hover_color="#922B21", command=lambda: self.remove_from_queue("", f)).pack(side="right", padx=5, pady=5) + ctk.CTkLabel(f, text="[JOB]", width=40, font=ctk.CTkFont(size=12, weight="bold"), text_color="#2ECC71").pack(side="left", padx=5) + l = ctk.CTkLabel(f, text=combined_title, anchor="w", font=ctk.CTkFont(size=12)) + l.pack(side="left", fill="x", expand=True, padx=5) + + merge_item = { + 'title': combined_title, + 'streamer': source_item['streamer'], + 'date': datetime.datetime.now(), + 'duration_str': dur_str, + 'ui_widget': f, + 'is_merge_job': True, + 'merge_mode': merge_mode, + 'sub_items': all_items + } + self.download_queue.append(merge_item) + self.lbl_queue_title.configure(text=f"Warteschlange ({len(self.download_queue)}):") + + def start_download_thread(self): + if not self.download_queue or self.is_downloading: return + self.is_downloading = True + self.btn_start.configure(state="normal", text="⏹ Abbrechen", fg_color="#C0392B", hover_color="#922B21", command=self.cancel_download) + self.btn_clear.configure(state="disabled") + threading.Thread(target=self.process_queue, daemon=True).start() + + def cancel_download(self): + self.is_downloading = False + self.log("Download wird abgebrochen...") + if self.current_process: + try: self.current_process.kill() + except: pass + + def process_queue(self): + global_mode = self.config.get("download_mode", "parts") + base_path = self.config["download_path"] + mins = self.config.get("part_minutes", 120) + part_seconds = mins * 60 + do_upload = self.config.get("upload_to_youtube", False) + profile_path = self.config.get("firefox_profile_path", "") + self.log(f"--- START QUEUE ---") + + for i, item in enumerate(self.download_queue, 1): + if not self.is_downloading: break + self.current_download_cancelled = False + + raw_streamer = item.get('streamer', 'Unbekannt') + streamer_name_clean = "".join([c for c in raw_streamer if c.isalpha() or c.isdigit() or c in " .-_"]).strip() + d_str = item['date'].strftime("%d.%m.%Y") + folder = os.path.join(base_path, streamer_name_clean, d_str) + try: os.makedirs(folder, exist_ok=True) + except Exception as e: + self.log(f"Fehler Ordner: {e}"); self.after(0, self.finish_download_process); return + + video_title = item['title'] + + def cancel_this_vod(): + self.log(f"Abbruch: {video_title}") + self.current_download_cancelled = True + if self.current_process: + try: self.current_process.kill() + except: pass + + vod_group = VODGroupRow(self.scroll_downloads, video_title, d_str, cancel_command=cancel_this_vod, theme_colors=THEMES[self.current_theme_name]) + files_downloaded = [] + + # --- MERGE JOB LOGIC --- + if item.get('is_merge_job', False): + self.log(f"Verarbeite Merge-Job: {video_title}") + temp_files = [] + merge_failed = False + job_merge_mode = item.get('merge_mode', 'split') # 'split' or 'full' + + for idx, sub in enumerate(item['sub_items']): + if not self.is_downloading or self.current_download_cancelled: break + + sub_fname = os.path.join(folder, f"TEMP_MERGE_{idx}.mp4") + + # --- SMART MERGE: Check if sub-item is a custom clip --- + if 'custom_clip' in sub: + c_data = sub['custom_clip'] + dur_sec = c_data['duration_sec'] + cmd = [*get_streamlink_cmd(), sub['url'], "best", + "--hls-start-offset", f"{c_data['start_sec']}s", + "--hls-duration", f"{dur_sec}s", + "-o", sub_fname, "--force"] + success = self.run_streamlink_process_with_cmd(cmd, sub_fname, f"DL Part {idx+1} (Cut)", dur_sec, vod_group) + else: + dur_sec = self.parse_duration_string(sub.get('duration_str', '4h')) + success = self.run_streamlink_process(sub['url'], sub_fname, f"DL Part {idx+1} (Full)", dur_sec, vod_group) + + if success: temp_files.append(sub_fname) + else: merge_failed = True; break + + if not merge_failed and not self.current_download_cancelled and len(temp_files) > 0: + merged_filename = os.path.join(folder, f"{d_str}_MERGED_FULL.mp4") + list_txt = os.path.join(folder, "concat_list.txt") + try: + with open(list_txt, "w", encoding="utf-8") as f: + for tf in temp_files: f.write(f"file '{tf}'\n") + except IOError as e: + self.log(f"Fehler beim Schreiben der concat_list: {e}") + merge_failed = True + + if not merge_failed: + use_split = (job_merge_mode == 'split') + + # Berechne Totalzeit für Progress (berücksichtige custom_clips) + def get_item_duration(s): + if 'custom_clip' in s: + return s['custom_clip'].get('duration_sec', 60) + return self.parse_duration_string(s.get('duration_str', '')) + total_time_estimate = sum([get_item_duration(s) for s in item['sub_items']]) + + concat_success = self.run_ffmpeg_concat_and_split( + list_txt, merged_filename, vod_group, use_split, part_seconds, folder, d_str, video_title, files_downloaded, total_time_estimate + ) + + try: + os.remove(list_txt) + for tf in temp_files: os.remove(tf) + except: pass + + # --- CUSTOM CLIP LOGIC --- + elif 'custom_clip' in item: + clip_data = item['custom_clip'] + clip_start_sec = clip_data['start_sec'] + clip_duration = clip_data['duration_sec'] + start_part = clip_data.get('start_part', 1) + filename_format = clip_data.get('filename_format', 'simple') + + def make_filename(part_num, start_offset): + if filename_format == 'timestamp': + time_str = time.strftime('%H-%M-%S', time.gmtime(clip_start_sec + start_offset)) + return os.path.join(folder, f"{d_str}_CLIP_{time_str}_Part{part_num}.mp4") + else: + return os.path.join(folder, f"{d_str}_Part{part_num}.mp4") + + # Wenn Clip länger als part_seconds, in Parts aufteilen + if clip_duration > part_seconds: + part = start_part + curr_offset = 0 + while curr_offset < clip_duration: + if not self.is_downloading or self.current_download_cancelled: + break + remaining = clip_duration - curr_offset + this_part_duration = min(part_seconds, remaining) + + filename = make_filename(part, curr_offset) + actual_start = clip_start_sec + curr_offset + + cmd = [*get_streamlink_cmd(), item['url'], "best", + "--hls-start-offset", f"{actual_start}s", + "--hls-duration", f"{this_part_duration}s", + "-o", filename, "--force"] + + success = self.run_streamlink_process_with_cmd(cmd, filename, f"Part {part}", this_part_duration, vod_group) + if success: + files_downloaded.append((filename, f"{video_title} - Part {part}")) + else: + break + + curr_offset += part_seconds + part += 1 + else: + # Kurzer Clip - als einzelne Datei mit Part-Nummer + filename = make_filename(start_part, 0) + cmd = [*get_streamlink_cmd(), item['url'], "best", + "--hls-start-offset", f"{clip_start_sec}s", + "--hls-duration", f"{clip_duration}s", + "-o", filename, "--force"] + + success = self.run_streamlink_process_with_cmd(cmd, filename, f"Part {start_part}", clip_duration, vod_group) + if success: + files_downloaded.append((filename, f"{video_title} - Part {start_part}")) + + # --- NORMAL JOB LOGIC --- + else: + if global_mode == "full": + filename = os.path.join(folder, f"{d_str}_Full.mp4") + est_sec = self.parse_duration_string(item.get('duration_str', '4h')) + success = self.run_streamlink_process(item['url'], filename, "Full VOD", est_sec, vod_group) + if success: files_downloaded.append((filename, f"{video_title} - Full")) + else: + part = 1 + curr_time = 0 + + # FIX: Ermitteln der Gesamtdauer + total_video_seconds = self.parse_duration_string(item.get('duration_str', '4h')) + + while True: + if not self.is_downloading: break + if self.current_download_cancelled: break + + # Sicherstellen, dass wir nicht übers Ziel hinausschießen + if curr_time >= total_video_seconds + 600: + self.log(f" > Gesamtdauer erreicht. Beende Parts.") + break + + filename = os.path.join(folder, f"{d_str}_Part{part}.mp4") + cmd = [*get_streamlink_cmd(), item['url'], "best", "--hls-start-offset", f"{curr_time}s", "--hls-duration", f"{part_seconds}s", "-o", filename, "--force"] + + success = self.run_streamlink_process_with_cmd(cmd, filename, f"Part {part}", part_seconds, vod_group) + + if success: + files_downloaded.append((filename, f"{video_title} - Part {part}")) + # FIX: Check auf korrupte/winzige Datei um Infinite Loop zu verhindern + try: + if os.path.exists(filename): + f_size_mb = os.path.getsize(filename) / (1024 * 1024) + # Wenn die Datei kleiner als 15MB ist UND wir erwarten eigentlich noch mehr Video + # Dann ist Streamlink wahrscheinlich abgebrochen oder Video ist zu Ende + if f_size_mb < 15 and (curr_time + part_seconds) < total_video_seconds: + self.log(f" > Part {part} zu klein ({f_size_mb:.2f}MB). Wahrscheinlich VOD-Ende erreicht. Stoppe Loop.") + break + except: pass + + if self.current_download_cancelled: break + + if not success: + break # Wenn Streamlink schon sagt "Fehler", Loop beenden + + curr_time += part_seconds; part += 1 + + # --- UPLOAD --- + if do_upload and files_downloaded and not self.current_download_cancelled: + self.log(f"Starte YouTube Uploads für '{video_title}'...") + for f_path, f_title in files_downloaded: + if not self.is_downloading: break + ui_refs = self.add_download_ui_row(f"UPLOAD: {f_title}", vod_group.content_frame) + ui_refs['lbl_status'].configure(text="Bereite vor...", text_color="#E5A00D") + self.perform_direct_selenium_upload(f_path, f_title, ui_refs, profile_path) + + self.after(0, vod_group.show_remove_button) + self.after(0, lambda w=item.get('ui_widget'): w.destroy() if w else None) + self.after(0, lambda r=len(self.download_queue) - i: self.lbl_queue_title.configure(text=f"Warteschlange ({r}):")) + + self.after(0, self.finish_download_process) + + def monitor_ffmpeg_progress(self, process, ui_refs, total_duration_us): + # Liest output line-by-line für Progress Bar + while True: + line = process.stdout.readline() + if not line: + if process.poll() is not None: break + continue + + if "out_time_us=" in line: + try: + current_us = int(line.strip().split("=")[1]) + if total_duration_us > 0: + percent = min(1.0, current_us / total_duration_us) + self.after(0, lambda p=percent: ui_refs['progress'].set(p)) + except: pass + + def run_ffmpeg_concat_and_split(self, list_file, output_file, vod_group, do_split, part_seconds, folder, d_str, vid_title, files_list, total_dur_sec): + # 1. CONCAT + ui_refs = self.add_download_ui_row("Verbinde Videos (FFmpeg)...", vod_group.content_frame) + + # -progress pipe:1 sorgt dafür, dass wir Statusupdates bekommen + cmd = [imageio_ffmpeg.get_ffmpeg_exe(), "-f", "concat", "-safe", "0", "-i", list_file, "-c", "copy", "-progress", "pipe:1", "-y", output_file] + + try: + self.current_process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, creationflags=CREATE_NO_WINDOW if os.name == 'nt' else 0, universal_newlines=True) + + # Start Monitor Thread + total_dur_us = total_dur_sec * 1_000_000 + t = threading.Thread(target=self.monitor_ffmpeg_progress, args=(self.current_process, ui_refs, total_dur_us), daemon=True) + t.start() + + while self.current_process.poll() is None: + if not self.is_downloading or self.current_download_cancelled: + self.current_process.kill() + self.update_download_ui_status(ui_refs, "Abgebrochen", "red") + return False + time.sleep(0.5) + + t.join() # Warte auf Thread + + if os.path.exists(output_file) and self.current_process.returncode == 0: + self.update_download_ui_status(ui_refs, "Verbunden", "green") + self.mark_row_finished(ui_refs) + + if not do_split: + files_list.append((output_file, f"{vid_title} - Merged Full")) + return True + else: + return self.run_ffmpeg_local_split(output_file, part_seconds, folder, d_str, vid_title, vod_group, files_list, total_dur_us) + else: + self.update_download_ui_status(ui_refs, "Fehler (Merge)", "red") + self.mark_row_finished(ui_refs) + return False + except Exception as e: + self.log(f"Merge Error: {e}") + self.update_download_ui_status(ui_refs, "Error", "red") + self.mark_row_finished(ui_refs) + return False + + def run_ffmpeg_local_split(self, input_file, segment_time, folder, d_str, vid_title, vod_group, files_list, total_dur_us): + ui_refs = self.add_download_ui_row("Splitte Video (FFmpeg)...", vod_group.content_frame) + + # New pattern: Datum_Part1.mp4, Datum_Part2.mp4 + pattern = os.path.join(folder, f"{d_str}_Part%d.mp4") + + cmd = [ + imageio_ffmpeg.get_ffmpeg_exe(), "-i", input_file, "-c", "copy", "-map", "0", + "-f", "segment", "-segment_time", str(segment_time), + "-segment_start_number", "1", # Start bei 1 + "-reset_timestamps", "1", + "-progress", "pipe:1", + "-y", pattern + ] + + try: + self.current_process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, creationflags=CREATE_NO_WINDOW if os.name == 'nt' else 0, universal_newlines=True) + + t = threading.Thread(target=self.monitor_ffmpeg_progress, args=(self.current_process, ui_refs, total_dur_us), daemon=True) + t.start() + + while self.current_process.poll() is None: + if not self.is_downloading or self.current_download_cancelled: + self.current_process.kill() + self.update_download_ui_status(ui_refs, "Abgebrochen", "red") + return False + time.sleep(0.5) + + t.join() + + if self.current_process.returncode == 0: + # Find generated files + generated = [f for f in os.listdir(folder) if f.startswith(f"{d_str}_") and f.endswith(".mp4") and "_Full" not in f and "MERGED" not in f] + generated.sort() + + self.update_download_ui_status(ui_refs, f"Fertig ({len(generated)} Parts)", "green") + self.mark_row_finished(ui_refs) + + for g in generated: + full_p = os.path.join(folder, g) + files_list.append((full_p, f"{vid_title} - {g}")) + + # Delete large source file + try: os.remove(input_file) + except: pass + + return True + else: + self.update_download_ui_status(ui_refs, "Fehler (Split)", "red") + self.mark_row_finished(ui_refs) + return False + + except Exception as e: + self.log(f"Split Error: {e}") + self.update_download_ui_status(ui_refs, "Error", "red") + self.mark_row_finished(ui_refs) + return False + + def perform_direct_selenium_upload(self, filepath, title, ui_refs, profile_path): + if not os.path.exists(filepath): + self.after(0, lambda: self.update_download_ui_status(ui_refs, "Datei fehlt", "red")) + self.after(0, lambda: self.mark_row_finished(ui_refs)) + return + driver = None + try: + self.after(0, lambda: self.update_download_ui_status(ui_refs, "Öffne Firefox...", "yellow")) + service = Service("geckodriver.exe"); options = Options() + options.add_argument("--disable-blink-features=AutomationControlled") + if profile_path and os.path.exists(profile_path): options.add_argument("-profile"); options.add_argument(profile_path) + local_app_data = os.getenv('LOCALAPPDATA', '') + possible_binaries = [r"C:\Program Files\Mozilla Firefox\firefox.exe", r"C:\Program Files (x86)\Mozilla Firefox\firefox.exe"] + if local_app_data: + possible_binaries.append(os.path.join(local_app_data, r"Mozilla Firefox\firefox.exe")) + for p in possible_binaries: + if os.path.exists(p): options.binary_location = p; break + driver = webdriver.Firefox(service=service, options=options) + driver.get("https://studio.youtube.com") + self.after(0, lambda: self.update_download_ui_status(ui_refs, "Warte auf Login...", "yellow")) + max_login_wait = 180; waited = 0 + while "studio.youtube.com" not in driver.current_url: + time.sleep(1); waited += 1 + if not self.is_downloading: + self.after(0, lambda: self.update_download_ui_status(ui_refs, "Abgebrochen", "red")) + self.after(0, lambda: self.mark_row_finished(ui_refs)) + driver.quit() + return + if waited > max_login_wait: + self.after(0, lambda: self.update_download_ui_status(ui_refs, "Timeout Login", "red")) + self.after(0, lambda: self.mark_row_finished(ui_refs)) + driver.quit() + return + self.after(0, lambda: self.update_download_ui_status(ui_refs, "Starte Upload...", "yellow")) + driver.get("https://www.youtube.com/upload"); time.sleep(2) + driver.find_element(By.XPATH, "//input[@type='file']").send_keys(os.path.abspath(filepath)) + self.after(0, lambda: self.update_download_ui_status(ui_refs, "Lade hoch (Warte auf 100%)...", "yellow")) + wait_counter = 0; upload_finished = False + while True: + if not self.is_downloading: break + try: + body_text = driver.find_element(By.TAG_NAME, "body").text + if "Verarbeitung" in body_text or "Processing" in body_text or "Upload abgeschlossen" in body_text or "Upload complete" in body_text or "Überprüfung" in body_text or "Checks" in body_text: + self.log("Upload Status: Fertig erkannt. Schließe Browser in 5s..."); time.sleep(5); upload_finished = True; break + time.sleep(2); wait_counter += 2 + if wait_counter > 7200: self.log("Timeout beim Upload (2h). Breche ab."); break + except: time.sleep(2) + if upload_finished: + self.after(0, lambda: self.update_download_ui_status(ui_refs, "Fertig (Draft)", "green")) + else: + self.after(0, lambda: self.update_download_ui_status(ui_refs, "Abbruch/Fehler", "red")) + self.after(0, lambda: self.mark_row_finished(ui_refs)) + driver.quit() + except Exception as e: + self.log(f"Selenium Error: {e}") + self.after(0, lambda: self.update_download_ui_status(ui_refs, "Fehler (Browser)", "red")) + self.after(0, lambda: self.mark_row_finished(ui_refs)) + if driver: + try: driver.quit() + except: pass + + def run_streamlink_process(self, url: str, filename: str, label: str, + duration_sec: int, parent_group) -> bool: + return self.run_streamlink_process_with_cmd( + [*get_streamlink_cmd(), url, "best", "-o", filename, "--force"], + filename, label, duration_sec, parent_group + ) + + def run_streamlink_process_with_cmd(self, cmd: List[str], filename: str, label: str, + duration_sec: int, parent_group) -> bool: + ui_refs = self.add_download_ui_row(label, parent_group.content_frame) + # Mindestens 60 Sekunden annehmen um Division durch 0 zu vermeiden + safe_duration = max(60, duration_sec) + target_size_bytes = safe_duration * ESTIMATED_BYTES_PER_SEC + try: + self.current_process = subprocess.Popen( + cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + creationflags=CREATE_NO_WINDOW if os.name == 'nt' else 0 + ) + last_check_time = time.time() + last_size = 0 + while self.current_process.poll() is None: + if not self.is_downloading: + self.current_process.kill() + return False + if self.current_download_cancelled: + self.current_process.kill() + self.after(0, lambda: self.update_download_ui_status(ui_refs, "Abgebrochen", "red")) + self.after(0, lambda: self.mark_row_finished(ui_refs)) + time.sleep(0.5) + if os.path.exists(filename): + try: + os.remove(filename) + except OSError: + pass + return False + if os.path.exists(filename): + current_size = os.path.getsize(filename) + current_time = time.time() + elapsed = current_time - last_check_time + if elapsed >= 1.0: + speed = (current_size - last_size) / elapsed + speed_str = f"{self.format_bytes(speed)}/s" + size_str = self.format_bytes(current_size) + percent = min(100, int((current_size / target_size_bytes) * 100)) + if percent >= 100: + percent = 99 + # ETA berechnen + bytes_remaining = max(0, target_size_bytes - current_size) + eta_str = self.format_eta(bytes_remaining, speed) + self.after(0, lambda p=percent/100, s=size_str, sp=speed_str, eta=eta_str: ( + ui_refs['progress'].set(p), + ui_refs['lbl_status'].configure(text=f"{s} | {sp} | ETA: {eta}") + )) + last_size = current_size + last_check_time = current_time + time.sleep(0.5) + self.current_process = None + if self.current_download_cancelled: + self.after(0, lambda: self.update_download_ui_status(ui_refs, "Abgebrochen", "red")) + if os.path.exists(filename): + try: os.remove(filename) + except: pass + return False + if os.path.exists(filename): + size = os.path.getsize(filename) + # Checke hier ob größer als 1 MB ist. Für den Loop Fix mache ich im Process Queue Loop noch einen genaueren Check. + if size < 1024 * 1024: + self.after(0, lambda: self.update_download_ui_status(ui_refs, "Fehler (zu klein)", "gray")) + self.after(0, lambda: self.mark_row_finished(ui_refs)) + try: os.remove(filename) + except: pass + return False + else: + self.after(0, lambda s=size: (ui_refs['progress'].set(1.0), ui_refs['progress'].configure(progress_color="green"), ui_refs['lbl_status'].configure(text=f"Fertig ({self.format_bytes(s)})", text_color="green"))) + self.log(f" > '{label}' fertig."); self.after(0, lambda: self.mark_row_finished(ui_refs)); return True + else: + self.after(0, lambda: self.update_download_ui_status(ui_refs, "Fehler: Datei fehlt", "red")) + self.after(0, lambda: self.mark_row_finished(ui_refs)); return False + except Exception as e: + self.log(f"Error: {e}"); self.after(0, lambda: self.update_download_ui_status(ui_refs, "Absturz", "red")); self.after(0, lambda: self.mark_row_finished(ui_refs)); return False + + def add_download_ui_row(self, label_text, parent_widget): + colors = THEMES[self.current_theme_name] + b_width = colors.get("border_width", 0) + b_color = colors.get("border_color", "transparent") + + if b_color == "transparent" and b_width > 0: b_color = "#333333" + + card = ctk.CTkFrame(parent_widget, fg_color=colors.get("bg_card", "gray25"), height=70, border_width=b_width, border_color=b_color) + card.pack(fill="x", pady=2, padx=5) + card.is_finished = False + header_frame = ctk.CTkFrame(card, fg_color="transparent") + header_frame.pack(side="top", fill="x", padx=10, pady=(5, 0)) + lbl_title = ctk.CTkLabel(header_frame, text=label_text, anchor="w", font=ctk.CTkFont(size=12)) + lbl_title.pack(side="left", fill="x", expand=True) + progress = ctk.CTkProgressBar(card, height=8) + progress.set(0) + progress.pack(side="top", fill="x", padx=10, pady=(8, 0)) + lbl_status = ctk.CTkLabel(card, text="Starte...", anchor="e", font=ctk.CTkFont(size=11), text_color="gray") + lbl_status.pack(side="top", fill="x", padx=10, pady=(2, 5)) + return {'frame': card, 'progress': progress, 'lbl_status': lbl_status, 'header_frame': header_frame} + + def mark_row_finished(self, ui_refs): + def do_mark(): + ui_refs['frame'].is_finished = True + btn_del = ctk.CTkButton(ui_refs['header_frame'], text="✖", width=25, height=20, fg_color="#C0392B", hover_color="#922B21", font=ctk.CTkFont(size=10, weight="bold"), command=lambda: ui_refs['frame'].destroy()) + btn_del.pack(side="right", padx=0) + # Thread-safe: UI-Updates immer über after() + self.after(0, do_mark) + + def clear_finished_downloads(self): + for group in self.scroll_downloads.winfo_children(): + if isinstance(group, VODGroupRow): + for row in list(group.content_frame.winfo_children()): + marked_finished = getattr(row, 'is_finished', False) + is_status_done = False + try: + for child in row.winfo_children(): + if isinstance(child, ctk.CTkLabel): + text = child.cget("text"); color = child.cget("text_color") + if color in ["green", "red"] or "Abgebrochen" in text or "Fertig" in text: is_status_done = True + except: pass + if marked_finished or is_status_done: row.destroy() + for group in list(self.scroll_downloads.winfo_children()): + if isinstance(group, VODGroupRow): + if not group.content_frame.winfo_children(): group.destroy() + + def update_download_ui_status(self, ui_refs, text, color): + def do_update(): + ui_refs['lbl_status'].configure(text=text, text_color=color) + if color == "green": ui_refs['progress'].configure(progress_color="green") + elif color == "red": ui_refs['progress'].configure(progress_color="red") + else: ui_refs['progress'].configure(progress_color="gray") + # Thread-safe: UI-Updates immer über after() + self.after(0, do_update) + + def format_bytes(self, size: float) -> str: + """Bytes in lesbare Groesse umwandeln (KB, MB, GB, TB).""" + if size < 0: + size = 0 + power = 1024 + n = 0 + power_labels = {0: '', 1: 'K', 2: 'M', 3: 'G', 4: 'T'} + while size > power and n < 4: + size /= power + n += 1 + return f"{size:.2f} {power_labels[n]}B" + + def parse_duration_string(self, dur_str: str) -> int: + """Duration-String (z.B. '2h30m15s') in Sekunden umwandeln.""" + seconds = 0 + try: + temp = dur_str.strip() + if not temp: + return 3600 + if 'h' in temp: + parts = temp.split('h') + h_val = parts[0].strip() + seconds += int(h_val) * 3600 if h_val else 0 + temp = parts[1] if len(parts) > 1 else "" + if 'm' in temp: + parts = temp.split('m') + m_val = parts[0].strip() + seconds += int(m_val) * 60 if m_val else 0 + temp = parts[1] if len(parts) > 1 else "" + if 's' in temp: + parts = temp.split('s') + s_val = parts[0].strip() + seconds += int(s_val) if s_val else 0 + except (ValueError, IndexError): + return 3600 * 4 + return seconds if seconds > 0 else 3600 + + def finish_download_process(self): + self.is_downloading = False + self.download_queue.clear() + self.lbl_queue_title.configure(text="Warteschlange (0):") + self.btn_start.configure(state="normal", text="▶ Start", fg_color="green", hover_color="darkgreen", command=self.start_download_thread) + self.btn_clear.configure(state="normal") + + def create_search_frame(self, parent): + frame = ctk.CTkFrame(parent, fg_color="transparent") + frame.grid_columnconfigure(0, weight=1); frame.grid_rowconfigure(2, weight=1) + top_bar = ctk.CTkFrame(frame, fg_color="transparent") + top_bar.grid(row=0, column=0, padx=20, pady=20, sticky="ew") + + self.entry_search_streamer = ctk.CTkEntry(top_bar, placeholder_text="Streamer Name", width=250, height=35) + self.entry_search_streamer.pack(side="left", padx=(0, 10)); self.entry_search_streamer.insert(0, self.config.get("streamer_name", "")) + + self.combo_filter = ctk.CTkOptionMenu(top_bar, values=["Frühere Übertragungen", "Highlights"], width=170, height=35) + self.combo_filter.pack(side="left", padx=(0, 10)) + + self.btn_load_vods = ctk.CTkButton(top_bar, text="Suchen", width=120, height=35, command=self.on_load_vods) + self.btn_load_vods.pack(side="left") + + self.lbl_status = ctk.CTkLabel(top_bar, text="", text_color="yellow") + self.lbl_status.pack(side="left", padx=15) + + header = ctk.CTkLabel(frame, text="Ergebnisse:", font=ctk.CTkFont(size=16, weight="bold")) + header.grid(row=1, column=0, padx=20, pady=(0, 10), sticky="w") + self.scroll_results = ctk.CTkScrollableFrame(frame, label_text="Suchergebnisse") + self.scroll_results.grid(row=2, column=0, padx=20, pady=(0, 20), sticky="nsew") + return frame + + def on_load_vods(self): + name = self.entry_search_streamer.get().strip() + filter_mode = self.combo_filter.get() + if not name or not self.token: self.update_status("Login erforderlich / Name fehlt", "red"); return + + # Streamer-Name in Config speichern + self.config["streamer_name"] = name + self.save_settings(silent=True) + + def fetch(): + try: + self.update_status("Lade ID...", "yellow") + headers = {'Client-ID': self.config["client_id"], 'Authorization': f'Bearer {self.token}'} + r = self.session.get('https://api.twitch.tv/helix/users', params={'login': name}, headers=headers, timeout=API_TIMEOUT) + try: + json_data = r.json() + except json.JSONDecodeError: + self.update_status("Ungültige API-Antwort", "red") + return + if not json_data.get('data'): + self.update_status("Nutzer nicht gefunden", "red") + return + user_data = json_data['data'][0] + user_id = user_data['id'] + display_name = user_data['display_name'] + self.after(0, lambda dn=display_name: self.title(f"Twitch VOD Manager [{APP_VERSION}] - {dn}")) + self.update_status(f"Lade {filter_mode} (max 50)...", "yellow") + + final_items = [] + v_type = 'highlight' if filter_mode == "Highlights" else 'archive' + r_v = self.session.get('https://api.twitch.tv/helix/videos', params={'user_id': user_id, 'type': v_type, 'first': 50}, headers=headers, timeout=API_TIMEOUT) + try: + raw_data = r_v.json().get('data', []) + except json.JSONDecodeError: + self.update_status("Ungültige Video-API-Antwort", "red") + return + for item in raw_data: + final_items.append({ + 'title': item['title'], 'url': item['url'], 'thumbnail_url': item['thumbnail_url'], + 'created_at': item['created_at'], 'duration': item['duration'], 'type': 'video' + }) + + self.after(0, lambda fi=final_items, dn=display_name: self.display_vods(fi, dn)) + self.update_status(f"{len(final_items)} Ergebnisse geladen", "green") + except Exception as e: self.update_status(f"API Fehler: {e}", "red") + threading.Thread(target=fetch, daemon=True).start() + + def open_clip_dialog(self, title, url, dt, streamer, dur_str): + dialog = tk.Toplevel(self) + dialog.title("Clip erstellen: " + title[:30]) + dialog.geometry("500x580") + dialog.configure(bg="#2b2b2b") + dialog.grab_set() + + total_seconds = self.parse_duration_string(dur_str) + + ctk.CTkLabel(dialog, text=f"Clip zuschneiden ({dur_str})", font=("Arial", 14, "bold"), text_color="#E5A00D").pack(pady=10) + + # Slider Frame + slider_frame = ctk.CTkFrame(dialog, fg_color="transparent") + slider_frame.pack(fill="x", padx=20, pady=5) + + ctk.CTkLabel(slider_frame, text="Start:").pack(anchor="w") + start_var = ctk.DoubleVar(value=0) + slider_start = ctk.CTkSlider(slider_frame, from_=0, to=total_seconds, variable=start_var) + slider_start.pack(fill="x", pady=(0, 5)) + + ctk.CTkLabel(slider_frame, text="Ende:").pack(anchor="w") + end_var = ctk.DoubleVar(value=min(60, total_seconds)) + slider_end = ctk.CTkSlider(slider_frame, from_=0, to=total_seconds, variable=end_var) + slider_end.pack(fill="x", pady=(0, 10)) + + # Zeit-Eingabefelder + time_frame = ctk.CTkFrame(dialog, fg_color="transparent") + time_frame.pack(fill="x", padx=20, pady=5) + + # Start-Zeit + start_row = ctk.CTkFrame(time_frame, fg_color="transparent") + start_row.pack(fill="x", pady=3) + ctk.CTkLabel(start_row, text="Startzeit (HH:MM:SS):", width=150).pack(side="left") + entry_start = ctk.CTkEntry(start_row, width=100) + entry_start.insert(0, "00:00:00") + entry_start.pack(side="left", padx=10) + + # End-Zeit + end_row = ctk.CTkFrame(time_frame, fg_color="transparent") + end_row.pack(fill="x", pady=3) + ctk.CTkLabel(end_row, text="Endzeit (HH:MM:SS):", width=150).pack(side="left") + entry_end = ctk.CTkEntry(end_row, width=100) + entry_end.insert(0, self.format_seconds(min(60, total_seconds))) + entry_end.pack(side="left", padx=10) + + lbl_info = ctk.CTkLabel(dialog, text="", text_color="gray") + lbl_info.pack(pady=5) + + def parse_time(time_str): + """Parst HH:MM:SS zu Sekunden""" + try: + parts = time_str.strip().split(':') + if len(parts) == 3: + return int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2]) + elif len(parts) == 2: + return int(parts[0]) * 60 + int(parts[1]) + else: + return int(parts[0]) + except: + return 0 + + updating = [False] # Flag um Endlos-Loop zu verhindern + + def update_from_slider(*args): + if updating[0]: return + updating[0] = True + entry_start.delete(0, "end") + entry_start.insert(0, self.format_seconds(int(start_var.get()))) + entry_end.delete(0, "end") + entry_end.insert(0, self.format_seconds(int(end_var.get()))) + update_info() + updating[0] = False + + def update_from_entry(*args): + if updating[0]: return + updating[0] = True + s = parse_time(entry_start.get()) + e = parse_time(entry_end.get()) + start_var.set(max(0, min(s, total_seconds))) + end_var.set(max(0, min(e, total_seconds))) + update_info() + updating[0] = False + + def update_info(): + s = parse_time(entry_start.get()) + e = parse_time(entry_end.get()) + s = max(0, min(s, total_seconds)) + e = max(0, min(e, total_seconds)) + if e > s: + lbl_info.configure(text=f"Dauer: {self.format_seconds(e - s)}", text_color="green") + else: + lbl_info.configure(text="Endzeit muss größer als Startzeit sein!", text_color="red") + + slider_start.configure(command=lambda v: update_from_slider()) + slider_end.configure(command=lambda v: update_from_slider()) + entry_start.bind("", update_from_entry) + entry_end.bind("", update_from_entry) + update_info() + + # Part-Nummer Eingabe + part_frame = ctk.CTkFrame(dialog, fg_color="transparent") + part_frame.pack(fill="x", padx=20, pady=5) + ctk.CTkLabel(part_frame, text="Start Part-Nummer (optional, für Fortsetzung):").pack(anchor="w") + entry_part = ctk.CTkEntry(part_frame, width=100, placeholder_text="z.B. 42") + entry_part.pack(anchor="w", pady=(3, 0)) + ctk.CTkLabel(part_frame, text="Leer lassen = Part 1", text_color="gray", font=("Arial", 10)).pack(anchor="w") + + # Dateinamen-Format Auswahl + format_frame = ctk.CTkFrame(dialog, fg_color="transparent") + format_frame.pack(fill="x", padx=20, pady=5) + ctk.CTkLabel(format_frame, text="Dateinamen-Format:").pack(anchor="w") + format_var = ctk.StringVar(value="simple") + ctk.CTkRadioButton(format_frame, text="01.02.2026_Part25.mp4 (Standard)", variable=format_var, value="simple").pack(anchor="w") + ctk.CTkRadioButton(format_frame, text="01.02.2026_CLIP_43-30-00_Part25.mp4 (mit Zeitstempel)", variable=format_var, value="timestamp").pack(anchor="w") + + def confirm(): + s = parse_time(entry_start.get()) + e = parse_time(entry_end.get()) + s = max(0, min(s, total_seconds)) + e = max(0, min(e, total_seconds)) + if e <= s: + messagebox.showerror("Fehler", "Endzeit muss größer als Startzeit sein.") + return + + # Part-Nummer auslesen + part_str = entry_part.get().strip() + start_part = 1 + if part_str and part_str.isdigit(): + start_part = max(1, int(part_str)) + + clip_data = { + 'start_sec': s, + 'duration_sec': e - s, + 'start_part': start_part, + 'filename_format': format_var.get() + } + self.add_to_queue(title, url, dt, streamer, dur_str, custom_clip=clip_data) + dialog.destroy() + + ctk.CTkButton(dialog, text="Zur Queue hinzufügen", command=confirm, fg_color="green").pack(pady=20) + + def display_vods(self, videos, streamer_name): + try: + for w in self.scroll_results.winfo_children(): + w.destroy() + if not videos: + ctk.CTkLabel(self.scroll_results, text="Keine Ergebnisse.").pack(pady=20) + return + + for v in videos: + colors = THEMES[self.current_theme_name] + b_width = colors.get("border_width", 0) + b_color = colors.get("border_color", "transparent") + + if b_color == "transparent" and b_width > 0: b_color = "#333333" + + card = ctk.CTkFrame(self.scroll_results, fg_color=colors.get("bg_card", "gray20"), height=80, border_width=b_width, border_color=b_color) + card.pack(fill="x", pady=5, padx=5) + + raw_url = v.get('thumbnail_url', '') or "" + thumb_url = raw_url.replace("%{width}", "160").replace("%{height}", "90") if "%{width}" in raw_url else raw_url + + lbl_thumb = ctk.CTkLabel(card, text="[BILD]", width=160, height=90, fg_color="black") + lbl_thumb.pack(side="left", padx=(5, 10), pady=5) + info_frame = ctk.CTkFrame(card, fg_color="transparent") + info_frame.pack(side="left", fill="both", expand=True, padx=5, pady=5) + + title = v.get('title', 'Unbekannt') + try: dt = datetime.datetime.strptime(v['created_at'], "%Y-%m-%dT%H:%M:%SZ") + except: dt = datetime.datetime.now() + + dur = v.get('duration', '?') + + ctk.CTkLabel(info_frame, text=title, anchor="w", font=ctk.CTkFont(size=14, weight="bold")).pack(fill="x") + ctk.CTkLabel(info_frame, text=f"{dt.strftime('%d.%m.%Y %H:%M')} | Dauer: {dur}", anchor="w", text_color="gray").pack(fill="x") + + btn_box = ctk.CTkFrame(card, fg_color="transparent") + btn_box.pack(side="right", padx=10, pady=20) + + btn_clip = ctk.CTkButton(btn_box, text="✂", width=40, fg_color="#D35400", hover_color="#A04000", command=lambda t=title, u=v['url'], d=dt, s=streamer_name, du=dur: self.open_clip_dialog(t, u, d, s, du)) + btn_clip.pack(side="left", padx=(0, 5)) + CTkToolTip(btn_clip, "Bestimmten Zeitbereich downloaden") + + btn_add = ctk.CTkButton(btn_box, text="➕ Zur Queue", width=100, command=lambda t=title, u=v['url'], d=dt, s=streamer_name, du=dur: self.add_to_queue(t, u, d, s, du)) + btn_add.pack(side="left") + + def load_img(u: str, l) -> None: + if not u: + return + try: + resp = self.session.get(u, stream=True, timeout=THUMBNAIL_TIMEOUT) + if resp.status_code == 200: + img_data = Image.open(BytesIO(resp.content)) + ctk_img = ctk.CTkImage(light_image=img_data, dark_image=img_data, size=(160, 90)) + l.image = ctk_img # Keep reference before scheduling + l.after(0, lambda img=ctk_img: l.configure(image=img, text="")) + except requests.RequestException: + pass + self.thumbnail_executor.submit(load_img, thumb_url, lbl_thumb) + except Exception as e: + self.log(f"UI Error: {e}") + messagebox.showerror("UI Error", f"Fehler beim Anzeigen der Liste:\n{e}") + + def add_to_queue(self, title, url, dt, streamer, dur, custom_clip=None): + if custom_clip is None: + for i in self.download_queue: + # Prüfe erst ob es kein merge_job ist, bevor auf 'url' zugegriffen wird + if 'is_merge_job' not in i and 'custom_clip' not in i and i.get('url') == url: + return + + colors = THEMES[self.current_theme_name] + b_width = colors.get("border_width", 0) + b_color = colors.get("border_color", "transparent") + + if b_color == "transparent" and b_width > 0: b_color = "#333333" + + f = ctk.CTkFrame(self.scroll_queue, fg_color=colors["bg_card"], height=40, border_width=b_width, border_color=b_color) + f.pack(fill="x", pady=2, padx=2) + + prefix = "✂ " if custom_clip else "" + item_ref = {'title': title, 'url': url, 'date': dt, 'streamer': streamer, 'duration_str': dur, 'ui_widget': f} + if custom_clip: + item_ref['custom_clip'] = custom_clip + + ctk.CTkButton(f, text="✖", width=30, height=25, fg_color="#C0392B", hover_color="#922B21", command=lambda: self.remove_from_queue(url, f)).pack(side="right", padx=5, pady=5) + + btn_merge = ctk.CTkButton(f, text="🔗", width=30, height=25, fg_color="#D35400", hover_color="#A04000", command=lambda i=item_ref: self.open_merge_dialog(i)) + btn_merge.pack(side="right", padx=5, pady=5) + CTkToolTip(btn_merge, "Verbinden mit...") + + date_label = dt.strftime("%d.%m") + ctk.CTkLabel(f, text=date_label, width=45, font=ctk.CTkFont(size=12, weight="bold"), text_color="#E5A00D").pack(side="left", padx=5) + + disp_title = (title[:25]+"...") if len(title)>25 else title + l = ctk.CTkLabel(f, text=prefix + disp_title, anchor="w", font=ctk.CTkFont(size=12)) + l.pack(side="left", fill="x", expand=True, padx=5) + CTkToolTip(l, title) + + self.download_queue.append(item_ref) + self.lbl_queue_title.configure(text=f"Warteschlange ({len(self.download_queue)}):") + + def remove_from_queue(self, url, widget): + self.download_queue = [x for x in self.download_queue if x.get('ui_widget') != widget] + widget.destroy() + self.lbl_queue_title.configure(text=f"Warteschlange ({len(self.download_queue)}):") + + def update_status(self, text, color): + # Thread-safe: UI-Updates immer über after() + self.after(0, lambda: self.lbl_status.configure(text=text, text_color=color)) + + def log(self, msg): + def do_log(): + try: + ts = datetime.datetime.now().strftime("%H:%M") + self.textbox_log.configure(state="normal") + self.textbox_log.insert("end", f"[{ts}] {msg}\n") + self.textbox_log.see("end") + self.textbox_log.configure(state="disabled") + except: + print(msg) + # Thread-safe: UI-Updates immer über after() + try: + self.after(0, do_log) + except: + print(msg) + + def load_config(self): + # Standard download_path auf Desktop\Twitch_VODs + default = { + "client_id": "", "client_secret": "", "download_path": DEFAULT_DOWNLOAD_PATH, + "streamer_name": "", "part_minutes": 120, "download_mode": "parts", + "upload_to_youtube": False, "youtube_desc": "Auto-Upload", "firefox_profile_path": "", + "theme": "Default" + } + if os.path.exists(CONFIG_FILE): + try: + with open(CONFIG_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + config = {**default, **data} + # Wenn download_path leer ist, auf Desktop setzen + if not config.get("download_path", "").strip(): + config["download_path"] = DEFAULT_DOWNLOAD_PATH + # Download-Ordner erstellen falls nicht vorhanden + if config["download_path"] and not os.path.exists(config["download_path"]): + try: + os.makedirs(config["download_path"], exist_ok=True) + except: + pass + return config + except: + pass + # Download-Ordner erstellen falls nicht vorhanden + if not os.path.exists(DEFAULT_DOWNLOAD_PATH): + try: + os.makedirs(DEFAULT_DOWNLOAD_PATH, exist_ok=True) + except: + pass + return default + + def save_part_minutes(self) -> None: + """Part-Minuten speichern.""" + try: + value = self.entry_minutes.get().strip() + if value and value.isdigit(): + self.config["part_minutes"] = int(value) + with open(CONFIG_FILE, "w", encoding="utf-8") as f: + json.dump(self.config, f, indent=4) + self.log(f"Part-Länge auf {value} Minuten gesetzt.") + except (ValueError, AttributeError): + pass + + def save_settings(self, silent=False): + # Alte Credentials merken um zu prüfen ob neu eingeloggt werden muss + old_client_id = self.config.get("client_id", "") + old_client_secret = self.config.get("client_secret", "") + + self.config["client_id"] = self.entry_client_id.get().strip() + self.config["client_secret"] = self.entry_client_secret.get().strip() + self.config["download_path"] = self.entry_path.get().strip() + self.config["download_mode"] = "parts" if "Parts" in self.seg_mode.get() else "full" + if hasattr(self, 'chk_upload_val'): + self.config["upload_to_youtube"] = self.chk_upload_val.get() + if hasattr(self, 'entry_profile_path'): + self.config["firefox_profile_path"] = self.entry_profile_path.get().strip() + if hasattr(self, 'var_theme'): + self.config["theme"] = self.var_theme.get() + if hasattr(self, 'entry_minutes'): + try: + val = self.entry_minutes.get().strip() + if val and val.isdigit(): + self.config["part_minutes"] = int(val) + except: + pass + try: + with open(CONFIG_FILE, "w", encoding="utf-8") as f: + json.dump(self.config, f, indent=4) + except IOError as e: + self.log(f"Fehler beim Speichern der Konfiguration: {e}") + return + if not silent: + self.log("Gespeichert.") + # Nur neu einloggen wenn Credentials geändert wurden + credentials_changed = (old_client_id != self.config["client_id"] or + old_client_secret != self.config["client_secret"]) + if credentials_changed and self.config["client_id"] and self.config["client_secret"]: + threading.Thread(target=self.perform_login, daemon=True).start() + + def browse_folder(self): + f = filedialog.askdirectory() + if f: + self.entry_path.delete(0, "end") + self.entry_path.insert(0, f) + self.save_settings(silent=True) + + def open_save_folder(self): + path = self.entry_path.get().strip() + if os.path.exists(path): + try: + os.startfile(path) + except Exception as e: + self.log(f"Fehler beim Öffnen: {e}") + else: + self.log("Ordner existiert nicht.") + + # ========================================== + # AUTO-UPDATER + # ========================================== + def check_for_updates_on_startup(self): + """Prüft beim Start auf Updates und zeigt Popup wenn verfügbar.""" + threading.Thread(target=self._startup_update_check, daemon=True).start() + + def _startup_update_check(self): + """Background-Check beim Start.""" + try: + import urllib.request + req = urllib.request.Request(UPDATE_CHECK_URL, headers={'User-Agent': 'Twitch-VOD-Manager'}) + with urllib.request.urlopen(req, timeout=10) as response: + data = json.loads(response.read().decode('utf-8')) + + server_version = data.get('version', '0.0.0') + download_url = data.get('download_url', '') + changelog = data.get('changelog', '') + + current = APP_VERSION.replace('v', '').strip() + server = server_version.replace('v', '').strip() + + def parse_version(v): + try: + return tuple(map(int, v.split('.'))) + except: + return (0, 0, 0) + + if parse_version(server) > parse_version(current): + self.latest_update_url = download_url + self.latest_update_version = server_version + self.latest_update_changelog = changelog + self.after(500, lambda: self._show_update_popup(server_version, changelog)) + except: + pass # Silent fail beim Start + + def _show_update_popup(self, version, changelog): + """Zeigt ein schönes Update-Popup.""" + popup = ctk.CTkToplevel(self) + popup.title("Update verfügbar") + popup.geometry("450x320") + popup.resizable(False, False) + popup.grab_set() + popup.configure(fg_color="#1a1a2e") + + # Zentrieren + popup.update_idletasks() + x = self.winfo_x() + (self.winfo_width() // 2) - 225 + y = self.winfo_y() + (self.winfo_height() // 2) - 160 + popup.geometry(f"+{x}+{y}") + + # Header mit Icon + header = ctk.CTkFrame(popup, fg_color="#16213e", corner_radius=10) + header.pack(fill="x", padx=20, pady=(20, 10)) + ctk.CTkLabel(header, text="🚀 Neues Update verfügbar!", font=("Arial", 18, "bold"), text_color="#00d4ff").pack(pady=15) + + # Version Info + info_frame = ctk.CTkFrame(popup, fg_color="transparent") + info_frame.pack(fill="x", padx=20, pady=5) + ctk.CTkLabel(info_frame, text=f"Aktuelle Version:", font=("Arial", 12), text_color="gray").pack(anchor="w") + ctk.CTkLabel(info_frame, text=f"{APP_VERSION}", font=("Arial", 14, "bold")).pack(anchor="w") + ctk.CTkLabel(info_frame, text=f"Neue Version:", font=("Arial", 12), text_color="gray").pack(anchor="w", pady=(10,0)) + ctk.CTkLabel(info_frame, text=f"v{version}", font=("Arial", 14, "bold"), text_color="#00ff88").pack(anchor="w") + + # Changelog + if changelog: + ctk.CTkLabel(info_frame, text=f"📋 {changelog}", font=("Arial", 11), text_color="#aaaaaa", wraplength=360).pack(anchor="w", pady=(10,0)) + + # Buttons + btn_frame = ctk.CTkFrame(popup, fg_color="transparent") + btn_frame.pack(fill="x", padx=20, pady=20) + + ctk.CTkButton( + btn_frame, text="⬇ Jetzt updaten", width=180, height=40, + font=("Arial", 14, "bold"), fg_color="#00aa55", hover_color="#008844", + command=lambda: self._start_silent_update(popup) + ).pack(side="left", padx=(0, 10)) + + ctk.CTkButton( + btn_frame, text="Später", width=180, height=40, + font=("Arial", 14), fg_color="#555555", hover_color="#444444", + command=popup.destroy + ).pack(side="right") + + def check_for_updates(self): + """Manueller Update-Check aus Einstellungen.""" + self.btn_check_update.configure(state="disabled", text="Suche...") + self.lbl_update_status.configure(text="Suche nach Updates...", text_color="yellow") + threading.Thread(target=self._manual_update_check, daemon=True).start() + + def _manual_update_check(self): + """Thread für manuellen Update-Check.""" + try: + import urllib.request + req = urllib.request.Request(UPDATE_CHECK_URL, headers={'User-Agent': 'Twitch-VOD-Manager'}) + with urllib.request.urlopen(req, timeout=10) as response: + data = json.loads(response.read().decode('utf-8')) + + server_version = data.get('version', '0.0.0') + download_url = data.get('download_url', '') + changelog = data.get('changelog', '') + + current = APP_VERSION.replace('v', '').strip() + server = server_version.replace('v', '').strip() + + def parse_version(v): + try: + return tuple(map(int, v.split('.'))) + except: + return (0, 0, 0) + + if parse_version(server) > parse_version(current): + self.latest_update_url = download_url + self.latest_update_version = server_version + self.latest_update_changelog = changelog + self.after(0, lambda: self._show_update_in_settings(server_version, changelog)) + else: + self.after(0, self._show_no_update) + + except Exception as e: + self.after(0, lambda: self._show_update_error(str(e))) + + def _show_update_in_settings(self, version, changelog): + """Zeigt Update-Info in Einstellungen.""" + self.btn_check_update.configure(state="normal", text="Nach Updates suchen") + self.lbl_update_status.configure( + text=f"✨ Update verfügbar: v{version}\n📋 {changelog}", + text_color="#00ff88" + ) + self.btn_download_update.configure(state="normal") + + def _show_no_update(self): + """Zeigt an dass kein Update verfügbar ist.""" + self.btn_check_update.configure(state="normal", text="Nach Updates suchen") + self.lbl_update_status.configure(text="✅ Du hast die neueste Version!", text_color="#00ff88") + self.btn_download_update.configure(state="disabled") + + def _show_update_error(self, error): + """Zeigt Update-Fehler an.""" + self.btn_check_update.configure(state="normal", text="Nach Updates suchen") + self.lbl_update_status.configure(text=f"❌ Fehler: {error}", text_color="red") + self.btn_download_update.configure(state="disabled") + + def download_and_install_update(self): + """Startet Update aus Einstellungen.""" + if not hasattr(self, 'latest_update_url') or not self.latest_update_url: + self.lbl_update_status.configure(text="Keine Update-URL!", text_color="red") + return + self._start_silent_update(None) + + def _start_silent_update(self, popup=None): + """Startet Silent-Update mit Progress-Dialog.""" + if popup: + popup.destroy() + + # Progress Dialog + self.update_dialog = ctk.CTkToplevel(self) + self.update_dialog.title("Update wird installiert...") + self.update_dialog.geometry("400x150") + self.update_dialog.resizable(False, False) + self.update_dialog.grab_set() + self.update_dialog.configure(fg_color="#1a1a2e") + + self.update_dialog.update_idletasks() + x = self.winfo_x() + (self.winfo_width() // 2) - 200 + y = self.winfo_y() + (self.winfo_height() // 2) - 75 + self.update_dialog.geometry(f"+{x}+{y}") + + ctk.CTkLabel(self.update_dialog, text="⬇ Update wird heruntergeladen...", font=("Arial", 14, "bold")).pack(pady=(20, 10)) + self.update_progress = ctk.CTkProgressBar(self.update_dialog, width=350) + self.update_progress.pack(pady=10) + self.update_progress.set(0) + self.update_status_label = ctk.CTkLabel(self.update_dialog, text="0%", font=("Arial", 12)) + self.update_status_label.pack() + + threading.Thread(target=self._download_and_install_silent, daemon=True).start() + + def _download_and_install_silent(self): + """Download und Silent-Installation - Komplett überarbeitet.""" + try: + import urllib.request + import tempfile + + temp_dir = tempfile.gettempdir() + installer_path = os.path.join(temp_dir, "Twitch_VOD_Manager_Update.exe") + + req = urllib.request.Request(self.latest_update_url, headers={'User-Agent': 'Twitch-VOD-Manager'}) + + with urllib.request.urlopen(req, timeout=300) as response: + total_size = int(response.headers.get('Content-Length', 0)) + downloaded = 0 + chunk_size = 65536 + + with open(installer_path, 'wb') as f: + while True: + chunk = response.read(chunk_size) + if not chunk: + break + f.write(chunk) + downloaded += len(chunk) + if total_size > 0: + progress = downloaded / total_size + percent = int(progress * 100) + self.after(0, lambda p=progress, pct=percent: self._update_progress(p, pct)) + + # Download fertig + self.after(0, lambda: self.update_status_label.configure(text="Starte Installation...")) + self.after(0, lambda: self.update_progress.set(1.0)) + + # VBScript erstellen das den Installer unsichtbar startet + vbs_path = os.path.join(temp_dir, "run_update.vbs") + with open(vbs_path, 'w') as f: + f.write('Set WshShell = CreateObject("WScript.Shell")\n') + f.write(f'WshShell.Run """{installer_path}"" /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /CLOSEAPPLICATIONS /RESTARTAPPLICATIONS", 0, False\n') + + # VBScript starten (läuft komplett unsichtbar) + os.startfile(vbs_path) + + # Installer schließt die App via /CLOSEAPPLICATIONS - wir warten nur kurz + self.after(2000, self.destroy) + + except Exception as e: + self.after(0, lambda: self.update_status_label.configure(text=f"Fehler: {e}")) + self.after(3000, lambda: self.update_dialog.destroy() if hasattr(self, 'update_dialog') else None) + + def _update_progress(self, progress, percent): + """Aktualisiert Progress-Dialog.""" + if hasattr(self, 'update_progress'): + self.update_progress.set(progress) + if hasattr(self, 'update_status_label'): + self.update_status_label.configure(text=f"{percent}%") + + def perform_login(self) -> None: + try: + resp = self.session.post( + 'https://id.twitch.tv/oauth2/token', + params={ + 'client_id': self.config["client_id"], + 'client_secret': self.config["client_secret"], + 'grant_type': 'client_credentials' + }, + timeout=API_TIMEOUT + ) + if resp.status_code == 200: + try: + self.token = resp.json().get('access_token') + self.log("Login erfolgreich.") + except json.JSONDecodeError: + self.log("Login Fehler: Ungültige API-Antwort") + else: + self.log(f"Login Fehler: {resp.status_code}") + except requests.RequestException as e: + self.log(f"Conn Error: {e}") + +if __name__ == "__main__": + try: + app = TwitchDownloaderApp() + app.mainloop() + except Exception as e: + log_crash(e) \ No newline at end of file diff --git a/installer.iss b/installer.iss new file mode 100644 index 0000000..20708c7 --- /dev/null +++ b/installer.iss @@ -0,0 +1,70 @@ +; Inno Setup Script for Twitch VOD Manager +; Version 3.3.5 + +#define MyAppName "Twitch VOD Manager" +#define MyAppVersion "3.5.3" +#define MyAppPublisher "Twitch VOD Manager" +#define MyAppExeName "Twitch_VOD_Manager.exe" + +[Setup] +AppId={{8A7B9C0D-1E2F-3A4B-5C6D-7E8F9A0B1C2D} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +AppPublisher={#MyAppPublisher} +DefaultDirName={autopf}\{#MyAppName} +DefaultGroupName={#MyAppName} +DisableProgramGroupPage=yes +OutputDir=installer_output +OutputBaseFilename=Twitch_VOD_Manager_Setup_{#MyAppVersion} +Compression=lzma2/ultra64 +SolidCompression=yes +WizardStyle=modern +PrivilegesRequired=admin + +[Languages] +Name: "german"; MessagesFile: "compiler:Languages\German.isl" +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked + +[Files] +Source: "dist\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion + +[Icons] +Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" +Name: "{group}\{cm:UninstallProgram,{#MyAppName}}"; Filename: "{uninstallexe}" +Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon + +[Run] +; Bei normaler Installation: Checkbox "Programm starten" +Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent runasoriginaluser + +[Code] +procedure CurStepChanged(CurStep: TSetupStep); +var + DataDir: String; + TempDir: String; + ResultCode: Integer; +begin + if CurStep = ssPostInstall then + begin + // Erstelle ProgramData Ordner fuer Settings + DataDir := ExpandConstant('{commonappdata}\Twitch_VOD_Manager'); + if not DirExists(DataDir) then + CreateDir(DataDir); + + // Bei Silent Install: Alte _MEI Ordner loeschen und App starten + if WizardSilent then + begin + Sleep(5000); // 5 Sekunden warten bis alte Prozesse beendet + + // Alte PyInstaller _MEI Ordner loeschen + TempDir := ExpandConstant('{tmp}\..'); + Exec('cmd.exe', '/c "for /d %i in ("' + TempDir + '\_MEI*") do rd /s /q "%i"" 2>nul', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); + + Sleep(1000); // Kurz warten nach Cleanup + Exec(ExpandConstant('{app}\{#MyAppExeName}'), '', '', SW_SHOW, ewNoWait, ResultCode); + end; + end; +end; diff --git a/server_files/version.json b/server_files/version.json new file mode 100644 index 0000000..64d5ee2 --- /dev/null +++ b/server_files/version.json @@ -0,0 +1,5 @@ +{ + "version": "3.5.3", + "download_url": "http://24-music.de/Twitch_VOD_Manager_Setup.exe", + "changelog": "Silent Update (kein CMD-Fenster)" +}