from __future__ import annotations import argparse import os import queue import threading import tkinter as tk from pathlib import Path from tkinter import filedialog, messagebox, ttk from tkinter.scrolledtext import ScrolledText from app_runtime import ( RuntimePaths, ensure_runtime_workspace, initialize_runtime_workspace, load_settings_with_defaults, save_settings as save_runtime_settings, ) from m3u_tools import ( ProbeConfig, ProbeResult, run_probe_file, shuffle_text_lines, trim_m3u8_directory, ) from video_pipeline import ( ConversionConfig, ConversionJob, ConversionResult, auto_rename_series, discover_series_jobs, discover_single_series, generate_playlist_txt, run_specific_jobs, ) RUNTIME_PATHS = ensure_runtime_workspace() APP_DIR = RUNTIME_PATHS.app_dir INPUT_DIR = RUNTIME_PATHS.input_dir OUTPUT_DIR = RUNTIME_PATHS.output_dir class VideoConvertApp(tk.Tk): def __init__(self) -> None: super().__init__() self.runtime_paths: RuntimePaths = RUNTIME_PATHS self.title('VideoConvert \u5de5\u5177\u7bb1') self.geometry('1480x900') self.minsize(1200, 760) self.settings = load_settings_with_defaults(self.runtime_paths) self.ui_queue: queue.Queue[dict] = queue.Queue() self.active_thread: threading.Thread | None = None self.active_stop_event: threading.Event | None = None self.active_target: str | None = None self.probe_passed = 0 self.probe_failed = 0 self.convert_succeeded = 0 self.convert_failed = 0 self.discovered_jobs: list[ConversionJob] = [] self._build_variables() self._build_style() self._build_layout() self._load_initial_state() self.after(120, self._drain_queue) self.protocol('WM_DELETE_WINDOW', self.on_close) def _build_variables(self) -> None: cpu_count = os.cpu_count() or 8 default_probe_workers = min(96, max(24, cpu_count * 2)) default_convert_jobs = min(4, max(1, (cpu_count + 4) // 8)) self.probe_input_var = tk.StringVar(value=self.settings.get('probe_input_file', str(RUNTIME_PATHS.test_file))) self.probe_output_var = tk.StringVar(value=self.settings.get('probe_output_file', str(RUNTIME_PATHS.pass_test_file))) self.probe_workers_var = tk.IntVar(value=self.settings.get('probe_workers', default_probe_workers)) self.probe_host_limit_var = tk.IntVar(value=self.settings.get('probe_host_limit', 3)) self.probe_timeout_var = tk.DoubleVar(value=self.settings.get('probe_timeout', 15.0)) self.probe_min_speed_var = tk.DoubleVar(value=self.settings.get('probe_min_speed', 400.0)) self.probe_sample_bytes_var = tk.IntVar(value=self.settings.get('probe_sample_bytes', 2048)) self.probe_sample_segments_var = tk.IntVar(value=self.settings.get('probe_sample_segments', 3)) self.probe_ffmpeg_fallback_var = tk.BooleanVar(value=self.settings.get('probe_ffmpeg_fallback', True)) self.convert_parallel_var = tk.IntVar(value=self.settings.get('convert_parallel_jobs', default_convert_jobs)) self.convert_resolution_var = tk.StringVar(value=self.settings.get('convert_resolution', '1280:720')) self.convert_video_bitrate_var = tk.StringVar(value=self.settings.get('convert_video_bitrate', '0')) self.convert_audio_bitrate_var = tk.StringVar(value=self.settings.get('convert_audio_bitrate', '96k')) self.convert_hls_time_var = tk.IntVar(value=self.settings.get('convert_hls_time', 10)) self.convert_preset_var = tk.StringVar(value=self.settings.get('convert_preset', 'p3')) self.convert_episode_mode_var = tk.BooleanVar(value=self.settings.get('convert_episode_mode', False)) self.convert_auto_rename_var = tk.BooleanVar(value=self.settings.get('convert_auto_rename', True)) self.shuffle_file_var = tk.StringVar(value=self.settings.get('shuffle_file', str(RUNTIME_PATHS.pass_test_file))) self.shuffle_keep_header_var = tk.BooleanVar(value=self.settings.get('shuffle_keep_header', True)) self.trim_dir_var = tk.StringVar(value=self.settings.get('trim_dir', str(RUNTIME_PATHS.del_line_dir))) self.trim_remove_var = tk.IntVar(value=self.settings.get('trim_remove_segments', 40)) self.trim_min_var = tk.IntVar(value=self.settings.get('trim_min_segments', 120)) self.trim_recursive_var = tk.BooleanVar(value=self.settings.get('trim_recursive', True)) self.probe_status_var = tk.StringVar(value='\u5f85\u673a') self.probe_summary_var = tk.StringVar(value='\u901a\u8fc7 0 / \u5931\u8d25 0') self.convert_status_var = tk.StringVar(value='\u5f85\u673a') self.convert_summary_var = tk.StringVar(value='\u6210\u529f 0 / \u5931\u8d25 0') self.convert_current_var = tk.StringVar(value='\u5f53\u524d\u6587\u4ef6\uff1a\u65e0') self.playlist_status_var = tk.StringVar(value='') self.playlist_dir_var = tk.StringVar(value=str(OUTPUT_DIR)) def _build_style(self) -> None: style = ttk.Style(self) if 'vista' in style.theme_names(): style.theme_use('vista') style.configure('Header.TLabel', font=('Microsoft YaHei UI', 15, 'bold')) style.configure('Hint.TLabel', foreground='#4f5f6f') style.configure('Status.TLabel', foreground='#1a5fb4') style.configure('Folder.TLabel', font=('Consolas', 10), foreground='#2e3436') def _build_layout(self) -> None: notebook = ttk.Notebook(self) notebook.pack(fill='both', expand=True, padx=12, pady=12) self.probe_tab = ttk.Frame(notebook, padding=12) self.convert_tab = ttk.Frame(notebook, padding=12) self.tools_tab = ttk.Frame(notebook, padding=12) notebook.add(self.probe_tab, text='M3U \u68c0\u6d4b') notebook.add(self.convert_tab, text='\u89c6\u9891\u8f6c\u5207\u7247') notebook.add(self.tools_tab, text='\u5e38\u7528\u5de5\u5177') self._build_probe_tab() self._build_convert_tab() self._build_tools_tab() # Probe tab def _build_probe_tab(self) -> None: ttk.Label(self.probe_tab, text='M3U \u94fe\u63a5\u68c0\u6d4b', style='Header.TLabel').pack(anchor='w') ttk.Label(self.probe_tab, text='HTTP \u76f4\u63a5\u91c7\u6837\u64ad\u653e\u5217\u8868\u548c\u5206\u7247\uff0c\u5fc5\u8981\u65f6\u81ea\u52a8\u56de\u9000 FFmpeg \u7ba1\u9053\u6d4b\u901f\u3002\u4e0d\u518d\u751f\u6210\u4e34\u65f6 ts \u6587\u4ef6\u3002', style='Hint.TLabel').pack(anchor='w', pady=(2, 10)) config_frame = ttk.LabelFrame(self.probe_tab, text='\u914d\u7f6e') config_frame.pack(fill='x', pady=(0, 10)) for c in range(4): config_frame.columnconfigure(c, weight=1) self._labeled_entry(config_frame, '\u8f93\u5165\u6587\u4ef6', self.probe_input_var, 0, 0, browse_file=True) self._labeled_entry(config_frame, '\u8f93\u51fa\u6587\u4ef6', self.probe_output_var, 0, 2, browse_save=True) self._labeled_entry(config_frame, '\u68c0\u6d4b\u7ebf\u7a0b', self.probe_workers_var, 1, 0, width=10) self._labeled_entry(config_frame, '\u5355\u4e3b\u673a\u5e76\u53d1', self.probe_host_limit_var, 1, 1, width=10) self._labeled_entry(config_frame, '\u8bf7\u6c42\u8d85\u65f6(\u79d2)', self.probe_timeout_var, 1, 2, width=10) self._labeled_entry(config_frame, '\u6700\u4f4e\u901f\u5ea6(KB/s)', self.probe_min_speed_var, 1, 3, width=12) self._labeled_entry(config_frame, '\u91c7\u6837\u5927\u5c0f(KB)', self.probe_sample_bytes_var, 2, 0, width=10) self._labeled_entry(config_frame, '\u91c7\u6837\u5206\u7247\u6570', self.probe_sample_segments_var, 2, 1, width=10) ttk.Checkbutton(config_frame, text='HTTP \u89e3\u6790\u5931\u8d25\u65f6\u5141\u8bb8 FFmpeg \u56de\u9000\u68c0\u6d4b', variable=self.probe_ffmpeg_fallback_var).grid(row=2, column=2, columnspan=2, sticky='w', padx=8, pady=8) action_frame = ttk.Frame(self.probe_tab) action_frame.pack(fill='x', pady=(0, 10)) self.probe_start_button = ttk.Button(action_frame, text='\u5f00\u59cb\u68c0\u6d4b', command=self.start_probe) self.probe_start_button.pack(side='left') self.probe_stop_button = ttk.Button(action_frame, text='\u505c\u6b62', command=self.stop_active_task, state='disabled') self.probe_stop_button.pack(side='left', padx=8) ttk.Label(action_frame, textvariable=self.probe_status_var, style='Status.TLabel').pack(side='left', padx=(12, 0)) ttk.Label(action_frame, textvariable=self.probe_summary_var).pack(side='left', padx=(12, 0)) self.probe_progress = ttk.Progressbar(self.probe_tab, mode='determinate') self.probe_progress.pack(fill='x', pady=(0, 10)) result_pane = ttk.Panedwindow(self.probe_tab, orient='vertical') result_pane.pack(fill='both', expand=True) tree_frame = ttk.Frame(result_pane) result_pane.add(tree_frame, weight=3) columns = ('channel', 'status', 'source', 'speed', 'samples', 'reason') self.probe_tree = ttk.Treeview(tree_frame, columns=columns, show='headings', height=15) headings = {'channel': '\u9891\u9053', 'status': '\u72b6\u6001', 'source': '\u68c0\u6d4b\u6e90', 'speed': '\u901f\u5ea6 KB/s', 'samples': '\u91c7\u6837', 'reason': '\u8bf4\u660e'} widths = {'channel': 220, 'status': 80, 'source': 90, 'speed': 100, 'samples': 80, 'reason': 520} for col in columns: self.probe_tree.heading(col, text=headings[col]) self.probe_tree.column(col, width=widths[col], anchor='w') scroll = ttk.Scrollbar(tree_frame, orient='vertical', command=self.probe_tree.yview) self.probe_tree.configure(yscrollcommand=scroll.set) self.probe_tree.pack(side='left', fill='both', expand=True) scroll.pack(side='right', fill='y') log_frame = ttk.LabelFrame(result_pane, text='\u65e5\u5fd7') result_pane.add(log_frame, weight=2) self.probe_log = ScrolledText(log_frame, height=12, font=('Consolas', 10)) self.probe_log.pack(fill='both', expand=True, padx=6, pady=6) # Convert tab def _build_convert_tab(self) -> None: ttk.Label(self.convert_tab, text='\u89c6\u9891\u8f6c HLS \u5207\u7247', style='Header.TLabel').pack(anchor='w') ttk.Label(self.convert_tab, text='HEVC NVENC \u7f16\u7801\uff0c720P\uff0cCQ 30 \u6781\u81f4\u7701\u7a7a\u95f4\u3002CPU+GPU \u6ee1\u8f7d\u52a0\u901f\u3002', style='Hint.TLabel').pack(anchor='w', pady=(2, 10)) folder_frame = ttk.LabelFrame(self.convert_tab, text='\u76ee\u5f55\u7ed3\u6784') folder_frame.pack(fill='x', pady=(0, 8)) inp = str(INPUT_DIR) out = str(OUTPUT_DIR) ttk.Label(folder_frame, text=f'\u8f93\u5165: {inp}/<\u7535\u89c6\u5267>/', style='Folder.TLabel').pack(anchor='w', padx=8, pady=(4, 0)) ttk.Label(folder_frame, text=f'\u8f93\u51fa: {out}/<\u7535\u89c6\u5267>/<\u96c6\u540d>/index.m3u8', style='Folder.TLabel').pack(anchor='w', padx=8, pady=(0, 4)) action_frame = ttk.Frame(self.convert_tab) action_frame.pack(fill='x', pady=(0, 8)) self.convert_scan_button = ttk.Button(action_frame, text='\u626b\u63cf input/', command=self.scan_conversion_jobs) self.convert_scan_button.pack(side='left') self.convert_pick_button = ttk.Button(action_frame, text='\u9009\u62e9\u5267\u96c6\u76ee\u5f55', command=self.pick_series_folder) self.convert_pick_button.pack(side='left', padx=8) self.convert_start_button = ttk.Button(action_frame, text='\u5f00\u59cb\u8f6c\u7801', command=self.start_convert) self.convert_start_button.pack(side='left', padx=(20, 0)) self.convert_stop_button = ttk.Button(action_frame, text='\u505c\u6b62', command=self.stop_active_task, state='disabled') self.convert_stop_button.pack(side='left', padx=8) ttk.Label(action_frame, textvariable=self.convert_status_var, style='Status.TLabel').pack(side='left', padx=(12, 0)) ttk.Label(action_frame, textvariable=self.convert_summary_var).pack(side='left', padx=(8, 0)) ttk.Label(action_frame, textvariable=self.convert_current_var).pack(side='right') self.convert_progress = ttk.Progressbar(self.convert_tab, mode='determinate') self.convert_progress.pack(fill='x', pady=(0, 8)) pane = ttk.Panedwindow(self.convert_tab, orient='vertical') pane.pack(fill='both', expand=True) jobs_frame = ttk.LabelFrame(pane, text='\u53d1\u73b0\u7684\u5267\u96c6\u4efb\u52a1') pane.add(jobs_frame, weight=2) jcols = ('name', 'mode', 'input', 'output', 'videos') self.jobs_tree = ttk.Treeview(jobs_frame, columns=jcols, show='headings', height=7) jheads = {'name': '\u5267\u96c6\u540d\u79f0', 'mode': '\u6765\u6e90', 'input': '\u8f93\u5165\u76ee\u5f55', 'output': '\u8f93\u51fa\u76ee\u5f55', 'videos': '\u89c6\u9891\u6570'} jw = {'name': 180, 'mode': 70, 'input': 420, 'output': 420, 'videos': 80} for col in jcols: self.jobs_tree.heading(col, text=jheads[col]) self.jobs_tree.column(col, width=jw[col], anchor='w') self.jobs_tree.pack(fill='both', expand=True, padx=6, pady=6) config_frame = ttk.LabelFrame(pane, text='\u8f6c\u7801\u53c2\u6570') pane.add(config_frame, weight=0) for c in range(5): config_frame.columnconfigure(c, weight=1) self._labeled_entry(config_frame, '\u5e76\u53d1\u4efb\u52a1', self.convert_parallel_var, 0, 0, width=8) self._labeled_entry(config_frame, '\u5206\u8fa8\u7387', self.convert_resolution_var, 0, 1, width=12) self._labeled_entry(config_frame, '\u89c6\u9891\u7801\u7387(0=CQ)', self.convert_video_bitrate_var, 0, 2, width=10) self._labeled_entry(config_frame, '\u97f3\u9891\u7801\u7387', self.convert_audio_bitrate_var, 0, 3, width=10) self._labeled_entry(config_frame, '\u5207\u7247\u65f6\u957f(\u79d2)', self.convert_hls_time_var, 0, 4, width=8) self._labeled_entry(config_frame, 'NVENC Preset', self.convert_preset_var, 1, 0, width=8) check_frame = ttk.Frame(config_frame) check_frame.grid(row=1, column=1, columnspan=3, sticky='w', padx=8, pady=6) ttk.Checkbutton(check_frame, text='\u6bcf\u96c6\u5355\u72ec\u5b50\u76ee\u5f55', variable=self.convert_episode_mode_var).pack(side='left') ttk.Checkbutton(check_frame, text='\u81ea\u52a8\u91cd\u547d\u540d\u6587\u4ef6(\u987a\u5e8f\u7f16\u53f7)', variable=self.convert_auto_rename_var).pack(side='left', padx=(16, 0)) result_frame = ttk.LabelFrame(pane, text='\u6587\u4ef6\u7ed3\u679c') pane.add(result_frame, weight=2) rcols = ('job', 'file', 'status', 'elapsed', 'message') self.convert_result_tree = ttk.Treeview(result_frame, columns=rcols, show='headings', height=7) rheads = {'job': '\u5267\u96c6', 'file': '\u6587\u4ef6', 'status': '\u72b6\u6001', 'elapsed': '\u8017\u65f6(s)', 'message': '\u8bf4\u660e'} rw = {'job': 180, 'file': 220, 'status': 80, 'elapsed': 90, 'message': 680} for col in rcols: self.convert_result_tree.heading(col, text=rheads[col]) self.convert_result_tree.column(col, width=rw[col], anchor='w') self.convert_result_tree.pack(fill='both', expand=True, padx=6, pady=6) log_frame = ttk.LabelFrame(pane, text='\u65e5\u5fd7') pane.add(log_frame, weight=1) self.convert_log = ScrolledText(log_frame, height=8, font=('Consolas', 10)) self.convert_log.pack(fill='both', expand=True, padx=6, pady=6) # Tools tab def _build_tools_tab(self) -> None: ttk.Label(self.tools_tab, text='\u5c0f\u5de5\u5177', style='Header.TLabel').pack(anchor='w') ttk.Label(self.tools_tab, text='\u6253\u4e71\u6587\u4ef6\u884c\u987a\u5e8f\u3001\u5220\u9664\u524d N \u4e2a\u5206\u7247\u7b49\u5e38\u7528\u529f\u80fd\u3002', style='Hint.TLabel').pack(anchor='w', pady=(2, 10)) sf = ttk.LabelFrame(self.tools_tab, text='\u6253\u4e71\u6587\u4ef6\u884c\u987a\u5e8f') sf.pack(fill='x', pady=(0, 10)) ttk.Label(sf, text='\u76ee\u6807\u6587\u4ef6').grid(row=0, column=0, sticky='w', padx=8, pady=8) ttk.Entry(sf, textvariable=self.shuffle_file_var).grid(row=0, column=1, sticky='ew', padx=8, pady=8) ttk.Button(sf, text='\u9009\u62e9\u6587\u4ef6', command=self.pick_shuffle_file).grid(row=0, column=2, padx=8, pady=8) ttk.Checkbutton(sf, text='\u4fdd\u7559\u7b2c\u4e00\u884c\u6ce8\u91ca\u5934', variable=self.shuffle_keep_header_var).grid(row=1, column=1, sticky='w', padx=8, pady=0) ttk.Button(sf, text='\u5f00\u59cb\u6253\u4e71', command=self.run_shuffle).grid(row=1, column=2, padx=8, pady=8) sf.columnconfigure(1, weight=1) tf = ttk.LabelFrame(self.tools_tab, text='\u5220\u9664\u524d\u7f6e\u5206\u7247') tf.pack(fill='x', pady=(0, 10)) ttk.Label(tf, text='\u76ee\u6807\u76ee\u5f55').grid(row=0, column=0, sticky='w', padx=8, pady=8) ttk.Entry(tf, textvariable=self.trim_dir_var).grid(row=0, column=1, sticky='ew', padx=8, pady=8) ttk.Button(tf, text='\u9009\u62e9\u76ee\u5f55', command=self.pick_trim_dir).grid(row=0, column=2, padx=8, pady=8) self._grid_labeled_entry(tf, '\u5220\u9664\u524d N \u7247', self.trim_remove_var, 1, 0) self._grid_labeled_entry(tf, '\u6700\u5c11\u5206\u7247\u9608\u503c', self.trim_min_var, 1, 1) ttk.Checkbutton(tf, text='\u9012\u5f52\u5904\u7406\u5b50\u76ee\u5f55', variable=self.trim_recursive_var).grid(row=1, column=2, sticky='w', padx=8, pady=8) bf = ttk.Frame(tf) bf.grid(row=2, column=2, sticky='e', padx=8, pady=8) ttk.Button(bf, text='\u9009\u62e9 output/ \u76ee\u5f55', command=self.pick_output_trim_dir).pack(side='left', padx=(0, 8)) ttk.Button(bf, text='\u5f00\u59cb\u5904\u7406', command=self.run_trim).pack(side='left') tf.columnconfigure(1, weight=1) pf = ttk.LabelFrame(self.tools_tab, text='\u751f\u6210\u64ad\u653e\u5217\u8868') pf.pack(fill='x', pady=(0, 10)) ttk.Entry(pf, textvariable=self.playlist_dir_var).pack(side='left', fill='x', expand=True, padx=8, pady=8) ttk.Button(pf, text='\u9009\u62e9\u76ee\u5f55', command=self.pick_playlist_dir).pack(side='left', padx=8, pady=8) ttk.Button(pf, text='\u751f\u6210\u64ad\u653e\u5217\u8868', command=self.run_generate_playlist).pack(side='left', padx=8, pady=8) ttk.Label(pf, textvariable=self.playlist_status_var).pack(side='left', padx=(12, 0)) scheme_frame = ttk.LabelFrame(self.tools_tab, text='\u63a8\u8350\u76ee\u5f55\u65b9\u6848') scheme_frame.pack(fill='both', expand=True) scheme_text = ScrolledText(scheme_frame, height=14, font=('Consolas', 10)) scheme_text.pack(fill='both', expand=True, padx=6, pady=6) scheme_text.insert('1.0', '\u63a8\u8350\u4f7f\u7528 input/ + output/ \u65b9\u6848\uff1a\n\n' 'input/\n' ' \u7504\u5ac2\u4f20/\n' ' 1.mp4\n 2.mp4\n 3.mp4\n' ' \u7405\u7430\u699c/\n' ' 1.mp4\n 2.mp4\n\n' 'output/\n' ' \u7504\u5ac2\u4f20/\n' ' 1/index.m3u8\n 2/index.m3u8\n' ' \u7405\u7430\u699c/\n' ' 1/index.m3u8\n\n' '\u7279\u70b9\uff1a\n' '1. input/ \u4e0b\u6bcf\u90e8\u7535\u89c6\u5267\u4e00\u4e2a\u6587\u4ef6\u5939\uff0c\u81ea\u52a8\u626b\u63cf\u8bc6\u522b\n' '2. \u652f\u6301\u81ea\u52a8\u91cd\u547d\u540d\uff1a\u5c06\u6587\u4ef6\u5939\u5185\u6240\u6709\u89c6\u9891\u6309\u987a\u5e8f\u7f16\u53f7\n' '3. \u7a7a\u76ee\u5f55\u81ea\u52a8\u8df3\u8fc7\uff0c\u7a0b\u5e8f\u4e0d\u4f1a\u5d29\u6e83\n' '4. \u652f\u6301\u201c\u9009\u62e9\u5267\u96c6\u76ee\u5f55\u201d\u6309\u94ae\u5904\u7406\u4efb\u610f\u4f4d\u7f6e\u7684\u5267\u96c6\u6587\u4ef6\u5939\n' '5. \u8f93\u51fa\u4fdd\u7559\u539f\u59cb\u76ee\u5f55\u540d\uff0c\u65b9\u4fbf\u540e\u671f\u6574\u7406\u548c\u64ad\u653e\n') scheme_text.configure(state='disabled') # Helper methods def _labeled_entry(self, parent, label, variable, row, column, width=None, browse_file=False, browse_save=False): frame = ttk.Frame(parent) frame.grid(row=row, column=column, padx=8, pady=6, sticky='ew') ttk.Label(frame, text=label).pack(anchor='w') entry = ttk.Entry(frame, textvariable=variable, width=width) entry.pack(side='left', fill='x', expand=True) if browse_file: ttk.Button(frame, text='\u6d4f\u89c8', command=lambda var=variable: self.pick_file(var)).pack(side='left', padx=(6, 0)) if browse_save: ttk.Button(frame, text='\u53e6\u5b58', command=lambda var=variable: self.pick_save_file(var)).pack(side='left', padx=(6, 0)) def _grid_labeled_entry(self, parent, label, variable, row, column): frame = ttk.Frame(parent) frame.grid(row=row, column=column, padx=8, pady=8, sticky='ew') ttk.Label(frame, text=label).pack(anchor='w') ttk.Entry(frame, textvariable=variable, width=12).pack(side='left', fill='x', expand=True) def _append_log(self, widget, message): widget.insert('end', message + '\n') widget.see('end') def _clear_probe_ui(self): self.probe_passed = 0 self.probe_failed = 0 self.probe_summary_var.set('\u901a\u8fc7 0 / \u5931\u8d25 0') self.probe_progress.configure(value=0, maximum=100) self.probe_tree.delete(*self.probe_tree.get_children()) self.probe_log.delete('1.0', 'end') def _clear_convert_ui(self): self.convert_succeeded = 0 self.convert_failed = 0 self.convert_summary_var.set('\u6210\u529f 0 / \u5931\u8d25 0') self.convert_progress.configure(value=0, maximum=100) self.convert_current_var.set('\u5f53\u524d\u6587\u4ef6\uff1a\u65e0') self.convert_result_tree.delete(*self.convert_result_tree.get_children()) self.convert_log.delete('1.0', 'end') def _set_busy(self, target, busy): self.active_target = target if busy else None if target == 'probe': self.probe_start_button.configure(state='disabled' if busy else 'normal') self.probe_stop_button.configure(state='normal' if busy else 'disabled') elif target == 'convert': self.convert_start_button.configure(state='disabled' if busy else 'normal') self.convert_stop_button.configure(state='normal' if busy else 'disabled') self.convert_scan_button.configure(state='disabled' if busy else 'normal') self.convert_pick_button.configure(state='disabled' if busy else 'normal') # Pick / Scan def pick_file(self, variable): path = filedialog.askopenfilename(initialdir=APP_DIR) if path: variable.set(path) def pick_save_file(self, variable): path = filedialog.asksaveasfilename(initialdir=APP_DIR) if path: variable.set(path) def pick_shuffle_file(self): self.pick_file(self.shuffle_file_var) def pick_trim_dir(self): path = filedialog.askdirectory(initialdir=self.trim_dir_var.get() or APP_DIR) if path: self.trim_dir_var.set(path) def pick_output_trim_dir(self): if OUTPUT_DIR.is_dir(): self.trim_dir_var.set(str(OUTPUT_DIR)) else: path = filedialog.askdirectory(initialdir=APP_DIR) if path: self.trim_dir_var.set(path) def scan_conversion_jobs(self): self.jobs_tree.delete(*self.jobs_tree.get_children()) self.discovered_jobs = [] config = ConversionConfig(root_dir=str(APP_DIR), episode_folder_mode=self.convert_episode_mode_var.get()) jobs = discover_series_jobs(APP_DIR, config) exts = ('.mp4', '.mkv', '.avi', '.mov', '.flv', '.wmv', '.m4v') for j in jobs: vc = sum(1 for f in j.input_dir.iterdir() if f.is_file() and f.suffix.lower() in exts) self.jobs_tree.insert('', 'end', values=(j.name, j.mode, str(j.input_dir), str(j.output_dir), vc)) self.discovered_jobs = jobs if not jobs: self._append_log(self.convert_log, f'\u672a\u5728 {INPUT_DIR}/ \u4e0b\u53d1\u73b0\u4efb\u4f55\u5267\u96c6\u76ee\u5f55\uff08\u6216\u76ee\u5f55\u4e3a\u7a7a\uff09\u3002') def pick_series_folder(self): path = filedialog.askdirectory(initialdir=str(INPUT_DIR)) if not path: return folder = Path(path) exts = ('.mp4', '.mkv', '.avi', '.mov', '.flv', '.wmv', '.m4v') video_count = sum(1 for f in folder.iterdir() if f.is_file() and f.suffix.lower() in exts) if video_count == 0: messagebox.showinfo('\u7a7a\u76ee\u5f55', f'\u6240\u9009\u76ee\u5f55\u4e2d\u6ca1\u6709\u627e\u5230\u89c6\u9891\u6587\u4ef6\uff1a\n{folder}') return job = ConversionJob(name=folder.name, input_dir=folder, output_dir=OUTPUT_DIR / folder.name, mode='\u81ea\u5b9a\u4e49') self.jobs_tree.delete(*self.jobs_tree.get_children()) self.discovered_jobs = [job] self.jobs_tree.insert('', 'end', values=(job.name, job.mode, str(job.input_dir), str(job.output_dir), video_count)) # Load / Save def _load_initial_state(self): self.scan_conversion_jobs() def _save_form_settings(self): payload = { 'probe_input_file': self.probe_input_var.get(), 'probe_output_file': self.probe_output_var.get(), 'probe_workers': self.probe_workers_var.get(), 'probe_host_limit': self.probe_host_limit_var.get(), 'probe_timeout': self.probe_timeout_var.get(), 'probe_min_speed': self.probe_min_speed_var.get(), 'probe_sample_bytes': self.probe_sample_bytes_var.get(), 'probe_sample_segments': self.probe_sample_segments_var.get(), 'probe_ffmpeg_fallback': self.probe_ffmpeg_fallback_var.get(), 'convert_parallel_jobs': self.convert_parallel_var.get(), 'convert_resolution': self.convert_resolution_var.get(), 'convert_video_bitrate': self.convert_video_bitrate_var.get(), 'convert_audio_bitrate': self.convert_audio_bitrate_var.get(), 'convert_hls_time': self.convert_hls_time_var.get(), 'convert_preset': self.convert_preset_var.get(), 'convert_episode_mode': self.convert_episode_mode_var.get(), 'convert_auto_rename': self.convert_auto_rename_var.get(), 'shuffle_file': self.shuffle_file_var.get(), 'shuffle_keep_header': self.shuffle_keep_header_var.get(), 'trim_dir': self.trim_dir_var.get(), 'trim_remove_segments': self.trim_remove_var.get(), 'trim_min_segments': self.trim_min_var.get(), 'trim_recursive': self.trim_recursive_var.get(), } save_runtime_settings(self.runtime_paths, payload) # Probe Actions def start_probe(self): if self.active_thread and self.active_thread.is_alive(): messagebox.showinfo('\u5fd9\u788c\u4e2d', '\u5f53\u524d\u5df2\u6709\u4efb\u52a1\u5728\u8fd0\u884c\uff0c\u8bf7\u5148\u505c\u6b62\u6216\u7b49\u5f85\u5b8c\u6210\u3002') return input_file = Path(self.probe_input_var.get()) if not input_file.exists(): messagebox.showerror('\u6587\u4ef6\u4e0d\u5b58\u5728', f'\u6ca1\u6709\u627e\u5230\u8f93\u5165\u6587\u4ef6\uff1a\n{input_file}') return self._save_form_settings() self._clear_probe_ui() self.probe_status_var.set('\u68c0\u6d4b\u4e2d') self._set_busy('probe', True) self.active_stop_event = threading.Event() config = ProbeConfig( input_file=self.probe_input_var.get(), output_file=self.probe_output_var.get(), max_workers=max(1, self.probe_workers_var.get()), per_host_limit=max(1, self.probe_host_limit_var.get()), request_timeout=max(1.0, self.probe_timeout_var.get()), min_speed_kbps=max(1.0, self.probe_min_speed_var.get()), sample_bytes=max(64, self.probe_sample_bytes_var.get()) * 1024, sample_segments=max(1, self.probe_sample_segments_var.get()), ffmpeg_fallback=self.probe_ffmpeg_fallback_var.get(), ) def worker(): summary = run_probe_file( config, event_callback=lambda payload: self.ui_queue.put({'target': 'probe', **payload}), stop_event=self.active_stop_event, ) self.ui_queue.put({'target': 'probe', 'kind': 'probe_summary', 'summary': summary}) self.active_thread = threading.Thread(target=worker, daemon=True) self.active_thread.start() def start_convert(self): if self.active_thread and self.active_thread.is_alive(): messagebox.showinfo('\u5fd9\u788c\u4e2d', '\u5f53\u524d\u5df2\u6709\u4efb\u52a1\u5728\u8fd0\u884c\uff0c\u8bf7\u5148\u505c\u6b62\u6216\u7b49\u5f85\u5b8c\u6210\u3002') return if not self.discovered_jobs: messagebox.showerror('\u65e0\u4efb\u52a1', '\u6ca1\u6709\u627e\u5230\u53ef\u8f6c\u7801\u7684\u89c6\u9891\u4efb\u52a1\u3002\u8bf7\u5c06\u89c6\u9891\u653e\u5165 input/<\u7535\u89c6\u5267>/ \u76ee\u5f55\u540e\u70b9\u51fb\u201c\u626b\u63cf input/\u201d\uff0c\u6216\u4f7f\u7528\u201c\u9009\u62e9\u5267\u96c6\u76ee\u5f55\u201d\u6309\u94ae\u3002') return if self.convert_auto_rename_var.get(): renamed_total = 0 for job in self.discovered_jobs: renamed_total += len(auto_rename_series(job.input_dir)) if renamed_total > 0: self._append_log(self.convert_log, f'\u5df2\u81ea\u52a8\u91cd\u547d\u540d {renamed_total} \u4e2a\u6587\u4ef6') self._save_form_settings() self._clear_convert_ui() self.convert_status_var.set('\u8f6c\u7801\u4e2d') self._set_busy('convert', True) self.active_stop_event = threading.Event() config = ConversionConfig( root_dir=str(APP_DIR), parallel_jobs=max(1, self.convert_parallel_var.get()), resolution=self.convert_resolution_var.get().strip(), video_bitrate=self.convert_video_bitrate_var.get().strip(), audio_bitrate=self.convert_audio_bitrate_var.get().strip(), hls_time=max(1, self.convert_hls_time_var.get()), preset=self.convert_preset_var.get().strip(), episode_folder_mode=self.convert_episode_mode_var.get(), ) jobs_copy = list(self.discovered_jobs) def worker(): summary = run_specific_jobs( jobs_copy, config, event_callback=lambda payload: self.ui_queue.put({'target': 'convert', **payload}), stop_event=self.active_stop_event, ) self.ui_queue.put({'target': 'convert', 'kind': 'convert_summary', 'summary': summary}) self.active_thread = threading.Thread(target=worker, daemon=True) self.active_thread.start() def stop_active_task(self): if self.active_stop_event: self.active_stop_event.set() if self.active_target == 'probe': self.probe_status_var.set('\u6b63\u5728\u505c\u6b62') elif self.active_target == 'convert': self.convert_status_var.set('\u6b63\u5728\u505c\u6b62') def run_shuffle(self): target_file = Path(self.shuffle_file_var.get()) if not target_file.exists(): messagebox.showerror('\u6587\u4ef6\u4e0d\u5b58\u5728', f'\u6ca1\u6709\u627e\u5230\u6587\u4ef6\uff1a\n{target_file}') return self._save_form_settings() count, path = shuffle_text_lines(target_file, keep_comment_header=self.shuffle_keep_header_var.get()) messagebox.showinfo('\u5b8c\u6210', f'\u5df2\u6253\u4e71 {count} \u884c\uff1a\n{path}') def run_trim(self): target_dir = Path(self.trim_dir_var.get()) if not target_dir.exists(): messagebox.showerror('\u76ee\u5f55\u4e0d\u5b58\u5728', f'\u6ca1\u6709\u627e\u5230\u76ee\u5f55\uff1a\n{target_dir}') return self._save_form_settings() results = trim_m3u8_directory( target_dir, remove_segments=max(1, self.trim_remove_var.get()), min_segments=max(1, self.trim_min_var.get()), recursive=self.trim_recursive_var.get(), ) processed = sum(1 for s, _, _ in results if s) skipped = len(results) - processed messagebox.showinfo('\u5b8c\u6210', f'\u5904\u7406\u5b8c\u6210\uff1a\u6210\u529f {processed} \u4e2a\uff0c\u8df3\u8fc7 {skipped} \u4e2a\u3002') # Queue Processing def _drain_queue(self): try: while True: item = self.ui_queue.get_nowait() target = item.get('target', '') kind = item.get('kind', '') payload = {k: v for k, v in item.items() if k not in ('target', 'kind')} if target == 'probe': self._handle_probe_event(kind, payload) elif target == 'convert': self._handle_convert_event(kind, payload) except queue.Empty: pass finally: self.after(120, self._drain_queue) def _handle_probe_event(self, kind, payload): if kind == 'log': line = payload.get('line') or f"[{payload.get('level', 'INFO')}] {payload.get('message', '')}" self._append_log(self.probe_log, line) return if kind == 'probe_result': result = payload['result'] if result.ok: self.probe_passed += 1 else: self.probe_failed += 1 finished = payload['finished'] total = max(payload['total'], 1) self.probe_progress.configure(maximum=total, value=finished) self.probe_summary_var.set(f'\u901a\u8fc7 {self.probe_passed} / \u5931\u8d25 {self.probe_failed}') self.probe_tree.insert('', 0, values=( result.channel, '\u901a\u8fc7' if result.ok else '\u5931\u8d25', result.source_type, f'{result.avg_speed_kbps:.1f}', f'{result.successful_samples}/{result.sample_count}', result.reason, )) return if kind == 'probe_summary': summary = payload['summary'] self.probe_status_var.set('\u5df2\u5b8c\u6210') self.probe_summary_var.set(f'\u901a\u8fc7 {summary.passed} / \u5931\u8d25 {summary.failed}') self._append_log(self.probe_log, f'\u7ed3\u679c\u5df2\u5199\u5165\uff1a{summary.output_file}\nCSV \u62a5\u544a\uff1a{summary.csv_report}\nJSON \u62a5\u544a\uff1a{summary.json_report}') self._set_busy('probe', False) self.active_thread = None self.active_stop_event = None def _handle_convert_event(self, kind, payload): if kind == 'log': line = payload.get('line') or f"[{payload.get('level', 'INFO')}] {payload.get('message', '')}" self._append_log(self.convert_log, line) return if kind == 'convert_progress': self.convert_current_var.set( f"\u5f53\u524d\u6587\u4ef6\uff1a{payload.get('job_name')} / {payload.get('file_name')} ({payload.get('percent')}%)") return if kind == 'convert_result': result = payload['result'] if result.success: self.convert_succeeded += 1 else: self.convert_failed += 1 finished = payload['finished'] total = max(payload['total'], 1) self.convert_progress.configure(maximum=total, value=finished) self.convert_summary_var.set(f'\u6210\u529f {self.convert_succeeded} / \u5931\u8d25 {self.convert_failed}') self.convert_result_tree.insert('', 0, values=( result.job_name, result.input_file.name, '\u901a\u8fc7' if result.success else '\u5931\u8d25', f'{result.elapsed_seconds:.1f}', result.message, )) return if kind == 'convert_summary': summary = payload['summary'] self.convert_status_var.set('\u5df2\u5b8c\u6210') self.convert_summary_var.set(f'\u6210\u529f {summary.succeeded} / \u5931\u8d25 {summary.failed}') self.convert_current_var.set('\u5f53\u524d\u6587\u4ef6\uff1a\u65e0') self._append_log(self.convert_log, f'\u65e5\u5fd7\u6587\u4ef6\uff1a{summary.log_file}') self._set_busy('convert', False) self.active_thread = None self.active_stop_event = None self.scan_conversion_jobs() def pick_playlist_dir(self): path = filedialog.askdirectory(initialdir=self.playlist_dir_var.get() or str(OUTPUT_DIR)) if path: self.playlist_dir_var.set(path) def run_generate_playlist(self): output_path = Path(self.playlist_dir_var.get()) if not output_path.is_dir(): messagebox.showerror('\u76ee\u5f55\u4e0d\u5b58\u5728', f'\u8f93\u51fa\u76ee\u5f55\u4e0d\u5b58\u5728\uff1a\\n{output_path}') return created = generate_playlist_txt(output_path) if created: names = '\n'.join(str(p.name) for p in created) self.playlist_status_var.set(f'\u751f\u6210 {len(created)} \u4e2a\u6587\u4ef6') messagebox.showinfo('\u5b8c\u6210', f'\u5df2\u751f\u6210 {len(created)} \u4e2a\u64ad\u653e\u5217\u6587\u4ef6\uff1a\\n{names}') else: self.playlist_status_var.set('\u672a\u627e\u5230\u5206\u7247\u6587\u4ef6') messagebox.showinfo('\u63d0\u793a', f'\u5728 {output_path}/ \u4e0b\u672a\u627e\u5230\u53ef\u751f\u6210\u64ad\u653e\u5217\u7684\u5267\u96c6\u76ee\u5f55\u3002') def on_close(self): self._save_form_settings() if self.active_thread and self.active_thread.is_alive(): if not messagebox.askyesno('\u4ecd\u6709\u4efb\u52a1\u8fd0\u884c', '\u5f53\u524d\u6709\u4efb\u52a1\u6b63\u5728\u8fd0\u884c\uff0c\u786e\u8ba4\u9000\u51fa\u5417\uff1f'): return if self.active_stop_event: self.active_stop_event.set() self.destroy() def build_parser(): parser = argparse.ArgumentParser(description='VideoConvert \u684c\u9762\u5de5\u5177') parser.add_argument('--init-only', action='store_true', help='\u53ea\u521d\u59cb\u5316 exe \u540c\u7ea7\u76ee\u5f55\u4e0b\u7684\u9ed8\u8ba4\u6587\u4ef6\u548c\u6587\u4ef6\u5939\uff0c\u7136\u540e\u9000\u51fa') return parser def main(): args = build_parser().parse_args() if args.init_only: info = initialize_runtime_workspace() print(f'\u521d\u59cb\u5316\u5b8c\u6210\uff1a{info["app_dir"]}') return app = VideoConvertApp() app.mainloop() if __name__ == '__main__': main()