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)