Files
videoconvert/videoconvert_gui.py
2026-06-12 10:59:32 +08:00

733 lines
39 KiB
Python

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>/<mp4\u6587\u4ef6>', 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()