提交必要文件

This commit is contained in:
2026-06-12 10:59:32 +08:00
parent 8b2ac635db
commit bb27963fc1
15 changed files with 2994 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
# Python
__pycache__/
*.py[cod]
*.pyo
*.egg-info/
*.egg
# Virtual environment
.venv/
venv/
# IDE
.idea/
.vscode/
*.swp
*.swo
# Codex
.codex/
# PyInstaller build output
dist/
build/
*.spec
# Runtime generated
logs/
reports/
pass_test.txt
test.txt
videoconvert_settings.json
*.bak
# Batch/build helpers (keep if you want to share)
# build_exe.bat
# rename.bat
# Downloaded/input/output data
input/
output/
download/
delLine/
# OS
Thumbs.db
Desktop.ini
+150
View File
@@ -0,0 +1,150 @@
from __future__ import annotations
import json
import sys
from dataclasses import dataclass, field
from pathlib import Path
DEFAULT_TEST_TXT = """# 频道,URL
# 示例:
# CCTV-1,http://example.com/live/index.m3u8
"""
DEFAULT_PASS_TEST_TXT = "# channel,url\n"
@dataclass(slots=True)
class RuntimePaths:
app_dir: Path
settings_file: Path
test_file: Path
pass_test_file: Path
input_dir: Path
output_dir: Path
logs_dir: Path
reports_dir: Path
del_line_dir: Path
download_dir: Path
def get_runtime_dir() -> Path:
if getattr(sys, "frozen", False):
return Path(sys.executable).resolve().parent
return Path(__file__).resolve().parent
def get_runtime_paths() -> RuntimePaths:
app_dir = get_runtime_dir()
return RuntimePaths(
app_dir=app_dir,
settings_file=app_dir / "videoconvert_settings.json",
test_file=app_dir / "test.txt",
pass_test_file=app_dir / "pass_test.txt",
input_dir=app_dir / "input",
output_dir=app_dir / "output",
logs_dir=app_dir / "logs",
reports_dir=app_dir / "reports",
del_line_dir=app_dir / "delLine",
download_dir=app_dir / "download",
)
def build_default_settings(paths: RuntimePaths) -> dict:
cpu_count = 8
try:
import os
cpu_count = os.cpu_count() or 8
except Exception:
pass
return {
"probe_input_file": str(paths.test_file),
"probe_output_file": str(paths.pass_test_file),
"probe_workers": min(96, max(24, cpu_count * 2)),
"probe_host_limit": 3,
"probe_timeout": 8.0,
"probe_min_speed": 400.0,
"probe_sample_bytes": 384,
"probe_sample_segments": 3,
"probe_ffmpeg_fallback": True,
"convert_root_dir": str(paths.app_dir),
"convert_parallel_jobs": min(4, max(2, (cpu_count + 2) // 6)),
"convert_resolution": "1280:720",
"convert_video_bitrate": "0",
"convert_audio_bitrate": "96k",
"convert_hls_time": 10,
"convert_preset": "p3",
"convert_episode_mode": False,
"shuffle_file": str(paths.pass_test_file),
"shuffle_keep_header": True,
"trim_dir": str(paths.del_line_dir),
"trim_remove_segments": 40,
"trim_min_segments": 120,
"trim_recursive": True,
}
def _ensure_text_file(path: Path, content: str) -> None:
if not path.exists():
path.write_text(content, encoding="utf-8")
def ensure_runtime_workspace() -> RuntimePaths:
paths = get_runtime_paths()
for directory in (
paths.app_dir,
paths.input_dir,
paths.output_dir,
paths.logs_dir,
paths.reports_dir,
paths.del_line_dir,
paths.download_dir,
):
directory.mkdir(parents=True, exist_ok=True)
_ensure_text_file(paths.test_file, DEFAULT_TEST_TXT)
_ensure_text_file(paths.pass_test_file, DEFAULT_PASS_TEST_TXT)
if not paths.settings_file.exists():
defaults = build_default_settings(paths)
paths.settings_file.write_text(json.dumps(defaults, ensure_ascii=False, indent=2), encoding="utf-8")
return paths
def initialize_runtime_workspace() -> dict[str, object]:
paths = ensure_runtime_workspace()
created = {
"app_dir": str(paths.app_dir),
"settings_file": str(paths.settings_file),
"test_file": str(paths.test_file),
"pass_test_file": str(paths.pass_test_file),
"input_dir": str(paths.input_dir),
"output_dir": str(paths.output_dir),
"logs_dir": str(paths.logs_dir),
"reports_dir": str(paths.reports_dir),
"del_line_dir": str(paths.del_line_dir),
"download_dir": str(paths.download_dir),
}
return created
def load_settings_with_defaults(paths: RuntimePaths | None = None) -> dict:
runtime_paths = paths or ensure_runtime_workspace()
defaults = build_default_settings(runtime_paths)
if runtime_paths.settings_file.exists():
try:
loaded = json.loads(runtime_paths.settings_file.read_text(encoding="utf-8"))
defaults.update(loaded)
except Exception:
pass
return defaults
def save_settings(paths: RuntimePaths, data: dict) -> None:
merged = build_default_settings(paths)
merged.update(data)
paths.settings_file.write_text(json.dumps(merged, ensure_ascii=False, indent=2), encoding="utf-8")
+11
View File
@@ -0,0 +1,11 @@
@echo off
setlocal
cd /d "%~dp0"
if exist ".venv\Scripts\python.exe" (
".venv\Scripts\python.exe" build_exe.py
) else (
py build_exe.py
)
endlocal
+59
View File
@@ -0,0 +1,59 @@
from __future__ import annotations
import shutil
import subprocess
import sys
from pathlib import Path
PROJECT_DIR = Path(__file__).resolve().parent
DIST_DIR = PROJECT_DIR / "dist"
BUILD_DIR = PROJECT_DIR / "build"
SPEC_FILE = PROJECT_DIR / "VideoConvert.spec"
ENTRY_FILE = PROJECT_DIR / "videoconvert_gui.py"
EXE_NAME = "VideoConvert"
def remove_if_exists(path: Path) -> None:
if path.is_dir():
shutil.rmtree(path, ignore_errors=True)
elif path.exists():
path.unlink()
def main() -> int:
remove_if_exists(DIST_DIR)
remove_if_exists(BUILD_DIR)
remove_if_exists(SPEC_FILE)
command = [
sys.executable,
"-m",
"PyInstaller",
"--noconfirm",
"--clean",
"--onefile",
"--windowed",
"--name",
EXE_NAME,
str(ENTRY_FILE),
]
print("开始打包 exe...")
print(" ".join(f'"{part}"' if " " in part else part for part in command))
completed = subprocess.run(command, cwd=PROJECT_DIR)
if completed.returncode != 0:
return completed.returncode
exe_path = DIST_DIR / f"{EXE_NAME}.exe"
if exe_path.exists():
print(f"\n打包完成:{exe_path}")
print("首次启动会自动在 exe 同级目录创建 input/、output/、test.txt、pass_test.txt、logs、reports 等默认结构。")
else:
print("\nPyInstaller 已执行,但没有找到生成的 exe。")
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+53
View File
@@ -0,0 +1,53 @@
from __future__ import annotations
import argparse
import threading
from video_pipeline import ConversionConfig, run_conversion_jobs
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="批量视频转 HLS 切片")
parser.add_argument("--root", default=".", help="任务根目录")
parser.add_argument("--jobs", type=int, default=None, help="并发转码任务数")
parser.add_argument("--resolution", default="1280:720", help="输出分辨率")
parser.add_argument("--video-bitrate", default="2000k", help="视频码率")
parser.add_argument("--audio-bitrate", default="128k", help="音频码率")
parser.add_argument("--hls-time", type=int, default=10, help="切片时长")
parser.add_argument("--preset", default="p4", help="NVENC preset")
parser.add_argument("--flat-output", action="store_true", help="关闭单集子目录模式")
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
config = ConversionConfig(
root_dir=args.root,
parallel_jobs=args.jobs or ConversionConfig().parallel_jobs,
resolution=args.resolution,
video_bitrate=args.video_bitrate,
audio_bitrate=args.audio_bitrate,
hls_time=max(1, args.hls_time),
preset=args.preset,
episode_folder_mode=not args.flat_output,
)
def on_event(payload: dict) -> None:
kind = payload.get("kind")
if kind == "log":
line = payload.get("line")
if line:
print(line)
summary = run_conversion_jobs(config, event_callback=on_event, stop_event=threading.Event())
print(
f"\n完成:成功 {summary.succeeded}/{summary.total},失败 {summary.failed}"
f"日志 {summary.log_file}"
)
return 0 if summary.failed == 0 else 1
if __name__ == "__main__":
raise SystemExit(main())
+5
View File
@@ -0,0 +1,5 @@
from convert import main
if __name__ == "__main__":
raise SystemExit(main())
+27
View File
@@ -0,0 +1,27 @@
from __future__ import annotations
import argparse
from m3u_tools import trim_m3u8_directory
def main() -> int:
parser = argparse.ArgumentParser(description="递归删除 m3u8 前置分片")
parser.add_argument("directory", nargs="?", default="delLine", help="目标目录")
parser.add_argument("--remove", type=int, default=40, help="删除前多少个分片")
parser.add_argument("--min-segments", type=int, default=120, help="最少分片阈值")
args = parser.parse_args()
results = trim_m3u8_directory(
args.directory,
remove_segments=max(1, args.remove),
min_segments=max(1, args.min_segments),
recursive=True,
)
processed = sum(1 for success, _, _ in results if success)
print(f"处理完成:成功 {processed} 个,跳过 {len(results) - processed}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+28
View File
@@ -0,0 +1,28 @@
from __future__ import annotations
import argparse
from m3u_tools import trim_m3u8_directory
def main() -> int:
parser = argparse.ArgumentParser(description="批量删除 m3u8 前置分片")
parser.add_argument("directory", nargs="?", default="delLine", help="目标目录")
parser.add_argument("--remove", type=int, default=40, help="删除前多少个分片")
parser.add_argument("--min-segments", type=int, default=40, help="最少分片阈值,低于则跳过")
parser.add_argument("--no-recursive", action="store_true", help="只处理当前目录")
args = parser.parse_args()
results = trim_m3u8_directory(
args.directory,
remove_segments=max(1, args.remove),
min_segments=max(1, args.min_segments),
recursive=not args.no_recursive,
)
processed = sum(1 for success, _, _ in results if success)
print(f"处理完成:成功 {processed} 个,跳过 {len(results) - processed}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+98
View File
@@ -0,0 +1,98 @@
path = 'D:\\proj\\videoconvert\\video_pipeline.py'
content = open(path, 'r', encoding='utf-8').read()
old_func = '''def generate_playlist_txt(
output_dir: str | Path,
url_prefix: str = \"http://192.168.10.32:2088\",
) -> list[Path]:
\"\"\"Scan output_dir for series folders, generate playlist txt files.
For each series, scans episode subdirs for m3u8 files and generates:
series_name + episode_name,url_prefix/series_folder/episode_folder/m3u8_file
One txt per series, placed in output_dir/.\"\"\"
output = Path(output_dir)
if not output.is_dir():
return []
created: list[Path] = []
for series_dir in sorted(output.iterdir(), key=lambda f: natural_sort_key(f.name)):
if not series_dir.is_dir():
continue
lines: list[str] = []
episode_dirs = sorted(
[d for d in series_dir.iterdir() if d.is_dir()],
key=lambda d: natural_sort_key(d.name),
)
for ep_dir in episode_dirs:
m3u8_files = sorted(ep_dir.glob(\"*.m3u8\"))
if not m3u8_files:
continue
m3u8_name = m3u8_files[0].name
display_name = series_dir.name + ep_dir.name
url = f\"{url_prefix}/{series_dir.name}/{ep_dir.name}/{m3u8_name}\"
lines.append(f\"{display_name},{url}\\n\")
if not lines:
continue
txt_path = output / f\"{series_dir.name}_playlist.txt\"
txt_path.write_text(\"\".join(lines), encoding=\"utf-8\")
created.append(txt_path)
return created'''
new_func = '''def generate_playlist_txt(
output_dir: str | Path,
url_prefix: str = \"http://192.168.10.32:2088\",
) -> list[Path]:
\"\"\"Scan output_dir for series folders, generate playlist txt files.
Supports both flat mode (m3u8 in series dir) and episode folder mode.
One txt per series, placed in output_dir/.\"\"\"
output = Path(output_dir)
if not output.is_dir():
return []
created: list[Path] = []
for series_dir in sorted(output.iterdir(), key=lambda f: natural_sort_key(f.name)):
if not series_dir.is_dir():
continue
lines: list[str] = []
# Flat mode: m3u8 files directly in series dir
flat_m3u8 = sorted(series_dir.glob(\"*.m3u8\"), key=lambda f: natural_sort_key(f.stem))
if flat_m3u8:
for m3u8_file in flat_m3u8:
ep_name = m3u8_file.stem
display_name = series_dir.name + ep_name
url = f\"{url_prefix}/{series_dir.name}/{m3u8_file.name}\"
lines.append(f\"{display_name},{url}\\n\")
else:
# Episode folder mode: m3u8 files in subdirectories
episode_dirs = sorted(
[d for d in series_dir.iterdir() if d.is_dir()],
key=lambda d: natural_sort_key(d.name),
)
for ep_dir in episode_dirs:
m3u8_files = sorted(ep_dir.glob(\"*.m3u8\"))
if not m3u8_files:
continue
m3u8_name = m3u8_files[0].name
display_name = series_dir.name + ep_dir.name
url = f\"{url_prefix}/{series_dir.name}/{ep_dir.name}/{m3u8_name}\"
lines.append(f\"{display_name},{url}\\n\")
if not lines:
continue
txt_path = output / f\"{series_dir.name}_playlist.txt\"
txt_path.write_text(\"\".join(lines), encoding=\"utf-8\")
created.append(txt_path)
return created'''
if old_func in content:
content = content.replace(old_func, new_func)
open(path, 'w', encoding='utf-8').write(content)
print('Replaced OK')
else:
print('Old function not found - checking around line 735')
lines = content.split('\\n')
for i in range(730, min(770, len(lines))):
print(f'{i+1}: {repr(lines[i][:120])}')
+919
View File
@@ -0,0 +1,919 @@
from __future__ import annotations
import csv
import json
import os
import random
import re
import ssl
import subprocess
from concurrent.futures import ThreadPoolExecutor, as_completed
import threading
import time
from dataclasses import asdict, dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Callable, Iterable
from urllib.error import HTTPError, URLError
from urllib.parse import urljoin, urlparse
from urllib.request import HTTPSHandler, Request, build_opener
DEFAULT_USER_AGENTS = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.2478.67",
)
TEXT_ENCODINGS = ("utf-8", "utf-8-sig", "gb18030", "gbk", "latin-1")
VIDEO_SEGMENT_EXTENSIONS = (
".ts",
".m4s",
".mp4",
".cmfv",
".aac",
".mp3",
".vtt",
)
VIDEO_FILE_EXTENSIONS = (".mp4", ".mkv", ".avi", ".mov", ".flv", ".wmv", ".m4v")
def natural_sort_key(value: str) -> list[object]:
return [int(token) if token.isdigit() else token.lower() for token in re.split(r"(\d+)", value)]
def discover_ffmpeg(preferred_path: str | Path | None = None, executable: str = "ffmpeg.exe") -> str:
if preferred_path:
candidate = Path(preferred_path)
if candidate.exists():
return str(candidate)
env_value = os.environ.get("FFMPEG_PATH")
if env_value and Path(env_value).exists():
return env_value
default_candidates = [
Path(r"C:\ffmpeg\bin") / executable,
Path.cwd() / executable,
]
for candidate in default_candidates:
if candidate.exists():
return str(candidate)
return executable
def discover_ffprobe(preferred_path: str | Path | None = None) -> str:
return discover_ffmpeg(preferred_path=preferred_path, executable="ffprobe.exe")
def slugify_filename(name: str) -> str:
cleaned = re.sub(r"[\\/:*?\"<>|]+", "_", name.strip())
cleaned = re.sub(r"\s+", " ", cleaned).strip()
return cleaned or "untitled"
def read_text_file(path: str | Path) -> list[str]:
file_path = Path(path)
for encoding in TEXT_ENCODINGS:
try:
with file_path.open("r", encoding=encoding) as handle:
return handle.readlines()
except UnicodeDecodeError:
continue
with file_path.open("r", encoding="utf-8", errors="replace") as handle:
return handle.readlines()
def ensure_directory(path: str | Path) -> Path:
directory = Path(path)
directory.mkdir(parents=True, exist_ok=True)
return directory
def subprocess_no_window_kwargs() -> dict[str, object]:
if os.name != "nt":
return {}
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
startupinfo.wShowWindow = getattr(subprocess, "SW_HIDE", 0)
kwargs: dict[str, object] = {
"startupinfo": startupinfo,
}
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
if creationflags:
kwargs["creationflags"] = creationflags
return kwargs
class OperationLogger:
def __init__(self, log_file: str | Path, callback: Callable[[str, str, str], None] | None = None) -> None:
self.log_file = Path(log_file)
self.callback = callback
self.lock = threading.Lock()
ensure_directory(self.log_file.parent)
def log(self, level: str, message: str) -> None:
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
line = f"[{timestamp}] [{level.upper()}] {message}"
with self.lock:
with self.log_file.open("a", encoding="utf-8") as handle:
handle.write(line + "\n")
if self.callback:
self.callback(level.upper(), message, line)
def info(self, message: str) -> None:
self.log("INFO", message)
def warning(self, message: str) -> None:
self.log("WARNING", message)
def error(self, message: str) -> None:
self.log("ERROR", message)
@dataclass(slots=True)
class ProbeConfig:
input_file: str = "test.txt"
output_file: str = "pass_test.txt"
report_dir: str = "reports"
log_dir: str = "logs"
ffmpeg_path: str = field(default_factory=discover_ffmpeg)
max_workers: int = field(default_factory=lambda: min(96, max(24, (os.cpu_count() or 8) * 2)))
per_host_limit: int = 3
request_timeout: float = 15.0
ffmpeg_timeout: float = 18.0
ffmpeg_probe_seconds: float = 5.0
sample_bytes: int = 2048 * 1024
sample_segments: int = 3
min_successful_segments: int = 2
min_total_bytes: int = 1024 * 1024
min_speed_kbps: float = 400.0
verify_tls: bool = False
write_header: bool = True
ffmpeg_fallback: bool = True
randomize_user_agent: bool = True
user_agents: tuple[str, ...] = DEFAULT_USER_AGENTS
@dataclass(slots=True)
class ProbeTarget:
index: int
channel: str
url: str
@dataclass(slots=True)
class SegmentSample:
url: str
ok: bool
bytes_read: int
elapsed_seconds: float
ttfb_ms: float
speed_kbps: float
status_code: int | None
error: str = ""
transfer_speed_kbps: float = 0.0
@dataclass(slots=True)
class ProbeResult:
index: int
channel: str
url: str
ok: bool
reason: str
source_type: str
final_playlist_url: str = ""
playlist_ms: float = 0.0
segment_count: int = 0
sample_count: int = 0
successful_samples: int = 0
total_bytes: int = 0
avg_speed_kbps: float = 0.0
peak_speed_kbps: float = 0.0
avg_ttfb_ms: float = 0.0
elapsed_seconds: float = 0.0
http_statuses: str = ""
error_detail: str = ""
avg_transfer_speed_kbps: float = 0.0
@dataclass(slots=True)
class ProbeSummary:
total: int
passed: int
failed: int
output_file: Path
csv_report: Path
json_report: Path
log_file: Path
elapsed_seconds: float
results: list[ProbeResult]
def parse_channel_file(path: str | Path) -> list[ProbeTarget]:
lines = read_text_file(path)
targets: list[ProbeTarget] = []
for index, raw_line in enumerate(lines):
line = raw_line.strip()
if not line or line.startswith("#") or "," not in line:
continue
channel, url = map(str.strip, line.split(",", 1))
if channel and url:
targets.append(ProbeTarget(index=len(targets), channel=channel, url=url))
return targets
def build_ssl_context(verify_tls: bool) -> ssl.SSLContext:
if verify_tls:
return ssl.create_default_context()
return ssl._create_unverified_context()
class M3UProber:
def __init__(
self,
config: ProbeConfig,
logger: OperationLogger | None = None,
event_callback: Callable[[dict], None] | None = None,
stop_event: threading.Event | None = None,
) -> None:
self.config = config
self.logger = logger
self.event_callback = event_callback
self.stop_event = stop_event or threading.Event()
self.ssl_context = build_ssl_context(config.verify_tls)
self.host_lock = threading.Lock()
self.host_limits: dict[str, threading.Semaphore] = {}
def emit(self, payload: dict) -> None:
if self.event_callback:
self.event_callback(payload)
def log(self, level: str, message: str) -> None:
if self.logger:
getattr(self.logger, level.lower(), self.logger.info)(message)
self.emit({"kind": "log", "level": level.upper(), "message": message})
def get_host_limiter(self, url: str) -> threading.Semaphore:
hostname = urlparse(url).netloc.lower() or "unknown"
with self.host_lock:
if hostname not in self.host_limits:
self.host_limits[hostname] = threading.Semaphore(self.config.per_host_limit)
return self.host_limits[hostname]
def make_headers(self) -> dict[str, str]:
if self.config.randomize_user_agent:
user_agent = random.choice(self.config.user_agents)
else:
user_agent = self.config.user_agents[0]
return {
"User-Agent": user_agent,
"Accept": "*/*",
}
def open_url(self, url: str, timeout: float, headers: dict[str, str] | None = None):
request = Request(url, headers=headers or self.make_headers())
opener = build_opener(HTTPSHandler(context=self.ssl_context))
return opener.open(request, timeout=timeout)
def fetch_text(self, url: str) -> tuple[str, float]:
started = time.perf_counter()
with self.open_url(url, timeout=self.config.request_timeout) as response:
payload = response.read(512 * 1024)
elapsed_ms = (time.perf_counter() - started) * 1000
text = payload.decode("utf-8", errors="replace")
return text, elapsed_ms
def fetch_playlist(self, url: str) -> tuple[str, str, float]:
text, elapsed_ms = self.fetch_text(url)
if "#EXTM3U" not in text and "#EXTINF" not in text and ".m3u8" not in url.lower():
raise ValueError("响应内容不是有效的 m3u8 播放列表")
return url, text, elapsed_ms
def parse_variant_playlists(self, playlist_url: str, playlist_text: str) -> list[tuple[int, str]]:
lines = [line.strip() for line in playlist_text.splitlines()]
variants: list[tuple[int, str]] = []
for index, line in enumerate(lines):
if not line.startswith("#EXT-X-STREAM-INF"):
continue
bandwidth_match = re.search(r"BANDWIDTH=(\d+)", line)
bandwidth = int(bandwidth_match.group(1)) if bandwidth_match else 0
next_index = index + 1
while next_index < len(lines):
candidate = lines[next_index]
if candidate and not candidate.startswith("#"):
variants.append((bandwidth, urljoin(playlist_url, candidate)))
break
next_index += 1
variants.sort(key=lambda item: item[0], reverse=True)
return variants
def parse_media_segments(self, playlist_url: str, playlist_text: str) -> tuple[list[str], bool]:
segments: list[str] = []
is_live = "#EXT-X-ENDLIST" not in playlist_text
for line in playlist_text.splitlines():
candidate = line.strip()
if not candidate or candidate.startswith("#"):
continue
if candidate.lower().endswith(".m3u8"):
continue
if "://" in candidate or candidate.startswith("/"):
segments.append(urljoin(playlist_url, candidate))
continue
if any(ext in candidate.lower() for ext in VIDEO_SEGMENT_EXTENSIONS):
segments.append(urljoin(playlist_url, candidate))
continue
segments.append(urljoin(playlist_url, candidate))
return segments, is_live
def select_sample_urls(self, segments: list[str], is_live: bool) -> list[str]:
if not segments:
return []
requested = max(1, self.config.sample_segments)
if len(segments) <= requested:
return segments
if is_live:
return segments[-requested:]
indices = {
round(position * (len(segments) - 1) / max(requested - 1, 1))
for position in range(requested)
}
return [segments[index] for index in sorted(indices)]
def probe_segment(self, url: str) -> SegmentSample:
limiter = self.get_host_limiter(url)
with limiter:
headers = self.make_headers()
headers["Range"] = f"bytes=0-{self.config.sample_bytes - 1}"
request = Request(url, headers=headers)
opener = build_opener(HTTPSHandler(context=self.ssl_context))
started = time.perf_counter()
try:
with opener.open(request, timeout=self.config.request_timeout) as response:
ttfb_ms = (time.perf_counter() - started) * 1000
status_code = getattr(response, "status", None)
transfer_started = time.perf_counter()
bytes_read = 0
while bytes_read < self.config.sample_bytes:
chunk = response.read(min(64 * 1024, self.config.sample_bytes - bytes_read))
if not chunk:
break
bytes_read += len(chunk)
elapsed = max(time.perf_counter() - started, 0.001)
transfer_elapsed = max(time.perf_counter() - transfer_started, 0.001)
speed_kbps = bytes_read / 1024 / elapsed
transfer_speed_kbps = bytes_read / 1024 / transfer_elapsed
return SegmentSample(
url=url,
ok=bytes_read > 0,
bytes_read=bytes_read,
elapsed_seconds=elapsed,
ttfb_ms=ttfb_ms,
speed_kbps=speed_kbps,
transfer_speed_kbps=transfer_speed_kbps,
status_code=status_code,
)
except HTTPError as error:
return SegmentSample(
url=url,
ok=False,
bytes_read=0,
elapsed_seconds=max(time.perf_counter() - started, 0.001),
ttfb_ms=0.0,
speed_kbps=0.0,
transfer_speed_kbps=0.0,
status_code=error.code,
error=f"HTTP {error.code}",
)
except URLError as error:
return SegmentSample(
url=url,
ok=False,
bytes_read=0,
elapsed_seconds=max(time.perf_counter() - started, 0.001),
ttfb_ms=0.0,
speed_kbps=0.0,
transfer_speed_kbps=0.0,
status_code=None,
error=f"网络错误: {error.reason}",
)
except Exception as error: # pragma: no cover - network edge cases
return SegmentSample(
url=url,
ok=False,
bytes_read=0,
elapsed_seconds=max(time.perf_counter() - started, 0.001),
ttfb_ms=0.0,
speed_kbps=0.0,
transfer_speed_kbps=0.0,
status_code=None,
error=str(error),
)
def ffmpeg_probe(self, target: ProbeTarget) -> ProbeResult:
started = time.perf_counter()
command = [
self.config.ffmpeg_path,
"-hide_banner",
"-loglevel",
"error",
"-rw_timeout",
str(int(self.config.request_timeout * 1_000_000)),
"-user_agent",
random.choice(self.config.user_agents),
"-i",
target.url,
"-t",
str(self.config.ffmpeg_probe_seconds),
"-map",
"0:v:0?",
"-map",
"0:a:0?",
"-c",
"copy",
"-f",
"mpegts",
"pipe:1",
]
try:
process = subprocess.Popen(
command,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
stdin=subprocess.DEVNULL,
**subprocess_no_window_kwargs(),
)
stdout, stderr = process.communicate(timeout=self.config.ffmpeg_timeout)
elapsed = max(time.perf_counter() - started, 0.001)
total_bytes = len(stdout)
speed_kbps = total_bytes / 1024 / elapsed
if process.returncode == 0 and total_bytes >= self.config.min_total_bytes:
return ProbeResult(
index=target.index,
channel=target.channel,
url=target.url,
ok=speed_kbps >= self.config.min_speed_kbps,
reason=(
f"FFmpeg回退成功 {speed_kbps:.1f} KB/s"
if speed_kbps >= self.config.min_speed_kbps
else f"FFmpeg回退速度不足 {speed_kbps:.1f} KB/s"
),
source_type="ffmpeg",
total_bytes=total_bytes,
avg_speed_kbps=speed_kbps,
peak_speed_kbps=speed_kbps,
elapsed_seconds=elapsed,
error_detail=stderr.decode("utf-8", errors="replace").strip(),
)
reason = "FFmpeg回退不可用"
if total_bytes and speed_kbps < self.config.min_speed_kbps:
reason = f"FFmpeg回退速度不足 {speed_kbps:.1f} KB/s"
return ProbeResult(
index=target.index,
channel=target.channel,
url=target.url,
ok=False,
reason=reason,
source_type="ffmpeg",
total_bytes=total_bytes,
avg_speed_kbps=speed_kbps,
peak_speed_kbps=speed_kbps,
elapsed_seconds=elapsed,
error_detail=stderr.decode("utf-8", errors="replace").strip(),
)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
return ProbeResult(
index=target.index,
channel=target.channel,
url=target.url,
ok=False,
reason="FFmpeg回退超时",
source_type="ffmpeg",
elapsed_seconds=time.perf_counter() - started,
)
except FileNotFoundError:
return ProbeResult(
index=target.index,
channel=target.channel,
url=target.url,
ok=False,
reason="FFmpeg 不存在",
source_type="ffmpeg",
)
except Exception as error: # pragma: no cover - subprocess edge cases
return ProbeResult(
index=target.index,
channel=target.channel,
url=target.url,
ok=False,
reason="FFmpeg回退异常",
source_type="ffmpeg",
error_detail=str(error),
)
def _fetch_playlist_throttled(self, url: str) -> tuple[str, str, float]:
limiter = self.get_host_limiter(url)
with limiter:
return self.fetch_playlist(url)
def probe_target(self, target: ProbeTarget) -> ProbeResult:
if self.stop_event.is_set():
return ProbeResult(
index=target.index,
channel=target.channel,
url=target.url,
ok=False,
reason="任务已停止",
source_type="stopped",
)
started = time.perf_counter()
try:
playlist_url, playlist_text, playlist_ms = self._fetch_playlist_throttled(target.url)
source_type = "media"
variants = self.parse_variant_playlists(playlist_url, playlist_text)
if variants:
source_type = "master"
variant_error: Exception | None = None
for _, variant_url in variants[:5]:
try:
playlist_url, playlist_text, variant_ms = self._fetch_playlist_throttled(variant_url)
playlist_ms += variant_ms
break
except Exception as error: # pragma: no cover - variant failover
variant_error = error
else:
raise variant_error or ValueError("没有可用的子播放列表")
segments, is_live = self.parse_media_segments(playlist_url, playlist_text)
if not segments:
raise ValueError("播放列表里没有可检测分片")
sample_urls = self.select_sample_urls(segments, is_live)
max_sample_workers = min(len(sample_urls), max(1, self.config.per_host_limit))
with ThreadPoolExecutor(max_workers=max_sample_workers) as sample_executor:
sample_futures = [sample_executor.submit(self.probe_segment, url) for url in sample_urls]
samples = [future.result() for future in as_completed(sample_futures)]
samples.sort(key=lambda s: sample_urls.index(s.url) if s.url in sample_urls else 999)
successful = [sample for sample in samples if sample.ok]
total_bytes = sum(sample.bytes_read for sample in successful)
elapsed_seconds = max(time.perf_counter() - started, 0.001)
avg_speed = sum(sample.speed_kbps for sample in successful) / max(len(successful), 1)
avg_transfer_speed = sum(sample.transfer_speed_kbps for sample in successful) / max(len(successful), 1)
peak_speed = max((sample.speed_kbps for sample in successful), default=0.0)
peak_transfer_speed = max((sample.transfer_speed_kbps for sample in successful), default=0.0)
avg_ttfb = sum(sample.ttfb_ms for sample in successful) / max(len(successful), 1)
statuses = ",".join(
str(sample.status_code) for sample in samples if sample.status_code is not None
)
def _fail(reason: str) -> ProbeResult:
return ProbeResult(
index=target.index,
channel=target.channel,
url=target.url,
ok=False,
reason=reason,
source_type=source_type,
final_playlist_url=playlist_url,
playlist_ms=playlist_ms,
segment_count=len(segments),
sample_count=len(samples),
successful_samples=len(successful),
total_bytes=total_bytes,
avg_speed_kbps=avg_speed,
peak_speed_kbps=peak_speed,
avg_transfer_speed_kbps=avg_transfer_speed,
avg_ttfb_ms=avg_ttfb,
elapsed_seconds=elapsed_seconds,
http_statuses=statuses,
error_detail=" | ".join(sample.error for sample in samples if sample.error),
)
def _pass(reason: str) -> ProbeResult:
return ProbeResult(
index=target.index,
channel=target.channel,
url=target.url,
ok=True,
reason=reason,
source_type=source_type,
final_playlist_url=playlist_url,
playlist_ms=playlist_ms,
segment_count=len(segments),
sample_count=len(samples),
successful_samples=len(successful),
total_bytes=total_bytes,
avg_speed_kbps=avg_speed,
peak_speed_kbps=peak_speed,
avg_transfer_speed_kbps=avg_transfer_speed,
avg_ttfb_ms=avg_ttfb,
elapsed_seconds=elapsed_seconds,
http_statuses=statuses,
error_detail=" | ".join(sample.error for sample in samples if sample.error),
)
if len(successful) < min(self.config.min_successful_segments, len(samples)):
return _fail(f"分片采样失败 {len(successful)}/{len(samples)}")
if total_bytes < self.config.min_total_bytes:
return _fail(f"有效数据不足 {total_bytes // 1024} KB")
min_speed = self.config.min_speed_kbps
# 判定顺序:传输速度优先 > 含连接速度 > 峰值速度(1.3倍冗余)
if avg_transfer_speed >= min_speed:
return _pass(f"可用 传输{avg_transfer_speed:.0f} KB/s")
if avg_speed >= min_speed:
return _pass(f"可用 {avg_speed:.0f} KB/s")
if peak_transfer_speed >= min_speed * 1.3:
return _pass(f"峰值通过 传输峰值{peak_transfer_speed:.0f} KB/s")
if peak_speed >= min_speed * 1.3:
return _pass(f"峰值通过 {peak_speed:.0f} KB/s")
return _fail(f"速度不足 传输{avg_transfer_speed:.0f}{avg_speed:.0f} KB/s")
except Exception as error:
if self.config.ffmpeg_fallback:
fallback = self.ffmpeg_probe(target)
if fallback.error_detail:
fallback.error_detail = f"{error} | {fallback.error_detail}"
else:
fallback.error_detail = str(error)
return fallback
return ProbeResult(
index=target.index,
channel=target.channel,
url=target.url,
ok=False,
reason="检测异常",
source_type="error",
elapsed_seconds=max(time.perf_counter() - started, 0.001),
error_detail=str(error),
)
def write_pass_list(results: Iterable[ProbeResult], output_file: str | Path, write_header: bool) -> Path:
output_path = Path(output_file)
ensure_directory(output_path.parent or Path("."))
with output_path.open("w", encoding="utf-8", newline="") as handle:
if write_header:
handle.write("# channel,url\n")
for result in results:
if result.ok:
handle.write(f"{result.channel},{result.url}\n")
return output_path
def write_probe_reports(
results: list[ProbeResult],
report_dir: str | Path,
elapsed_seconds: float,
log_file: str | Path,
output_file: str | Path,
) -> tuple[Path, Path]:
report_root = ensure_directory(report_dir)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
csv_path = report_root / f"probe_report_{timestamp}.csv"
json_path = report_root / f"probe_report_{timestamp}.json"
ordered_results = sorted(results, key=lambda item: item.index)
with csv_path.open("w", encoding="utf-8-sig", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=list(asdict(ordered_results[0]).keys()) if ordered_results else [
"index",
"channel",
"url",
"ok",
"reason",
"source_type",
"final_playlist_url",
"playlist_ms",
"segment_count",
"sample_count",
"successful_samples",
"total_bytes",
"avg_speed_kbps",
"peak_speed_kbps",
"avg_transfer_speed_kbps",
"avg_ttfb_ms",
"elapsed_seconds",
"http_statuses",
"error_detail",
])
writer.writeheader()
for result in ordered_results:
writer.writerow(asdict(result))
payload = {
"generated_at": datetime.now().isoformat(timespec="seconds"),
"elapsed_seconds": elapsed_seconds,
"log_file": str(Path(log_file)),
"output_file": str(Path(output_file)),
"totals": {
"total": len(ordered_results),
"passed": sum(1 for item in ordered_results if item.ok),
"failed": sum(1 for item in ordered_results if not item.ok),
},
"results": [asdict(item) for item in ordered_results],
}
with json_path.open("w", encoding="utf-8") as handle:
json.dump(payload, handle, ensure_ascii=False, indent=2)
return csv_path, json_path
def run_probe_file(
config: ProbeConfig,
event_callback: Callable[[dict], None] | None = None,
stop_event: threading.Event | None = None,
) -> ProbeSummary:
ensure_directory(config.report_dir)
ensure_directory(config.log_dir)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
log_file = Path(config.log_dir) / f"probe_{timestamp}.log"
logger = OperationLogger(log_file, callback=lambda level, message, line: event_callback({"kind": "log", "level": level, "message": message, "line": line}) if event_callback else None)
prober = M3UProber(config=config, logger=logger, event_callback=event_callback, stop_event=stop_event)
targets = parse_channel_file(config.input_file)
logger.info(
f"开始检测 {len(targets)} 条链接,线程 {config.max_workers},每主机并发 {config.per_host_limit},速度阈值 {config.min_speed_kbps:.0f} KB/s"
)
if not targets:
output_path = write_pass_list([], config.output_file, config.write_header)
csv_path, json_path = write_probe_reports([], config.report_dir, 0.0, log_file, output_path)
return ProbeSummary(
total=0,
passed=0,
failed=0,
output_file=output_path,
csv_report=csv_path,
json_report=json_path,
log_file=log_file,
elapsed_seconds=0.0,
results=[],
)
started = time.perf_counter()
results: list[ProbeResult] = []
finished = 0
with ThreadPoolExecutor(max_workers=config.max_workers) as executor:
futures = {executor.submit(prober.probe_target, target): target for target in targets}
for future in as_completed(futures):
result = future.result()
results.append(result)
finished += 1
state = "PASS" if result.ok else "FAIL"
logger.info(
f"[{state}] {finished}/{len(targets)} {result.channel} | {result.reason} | {result.source_type} | "
f"传输{result.avg_transfer_speed_kbps:.0f}/总{result.avg_speed_kbps:.0f} KB/s | {result.successful_samples}/{max(result.sample_count, 1)} 样本"
)
if event_callback:
event_callback(
{
"kind": "probe_result",
"finished": finished,
"total": len(targets),
"result": result,
}
)
if stop_event and stop_event.is_set():
logger.warning("收到停止信号,未完成任务会在当前请求结束后退出。")
ordered_results = sorted(results, key=lambda item: item.index)
output_path = write_pass_list(ordered_results, config.output_file, config.write_header)
elapsed_seconds = time.perf_counter() - started
csv_path, json_path = write_probe_reports(
ordered_results,
config.report_dir,
elapsed_seconds,
log_file,
output_path,
)
passed = sum(1 for item in ordered_results if item.ok)
failed = len(ordered_results) - passed
logger.info(f"检测完成:通过 {passed}/{len(ordered_results)},耗时 {elapsed_seconds:.1f}")
return ProbeSummary(
total=len(ordered_results),
passed=passed,
failed=failed,
output_file=output_path,
csv_report=csv_path,
json_report=json_path,
log_file=log_file,
elapsed_seconds=elapsed_seconds,
results=ordered_results,
)
def shuffle_text_lines(file_path: str | Path, keep_comment_header: bool = True) -> tuple[int, Path]:
path = Path(file_path)
lines = read_text_file(path)
header: list[str] = []
body = lines
if keep_comment_header and lines and lines[0].lstrip().startswith("#"):
header = [lines[0]]
body = lines[1:]
random.shuffle(body)
with path.open("w", encoding="utf-8") as handle:
handle.writelines(header + body)
return len(body), path
def collect_playlist_segments(lines: list[str]) -> list[tuple[int, int]]:
segments: list[tuple[int, int]] = []
for index, line in enumerate(lines):
if not line.strip().startswith("#EXTINF"):
continue
next_index = index + 1
while next_index < len(lines):
candidate = lines[next_index].strip()
if not candidate:
next_index += 1
continue
if candidate.startswith("#"):
break
segments.append((index, next_index))
break
return segments
def trim_m3u8_file(
file_path: str | Path,
remove_segments: int = 40,
min_segments: int = 120,
) -> tuple[bool, int, Path]:
path = Path(file_path)
lines = read_text_file(path)
segments = collect_playlist_segments(lines)
if len(segments) <= min_segments:
return False, len(segments), path
to_delete: set[int] = set()
for extinf_line, segment_line in segments[:remove_segments]:
to_delete.add(extinf_line)
to_delete.add(segment_line)
new_lines: list[str] = []
media_sequence_updated = False
for index, line in enumerate(lines):
if index in to_delete:
continue
if line.startswith("#EXT-X-MEDIA-SEQUENCE:"):
try:
current_sequence = int(line.split(":", 1)[1].strip())
except ValueError:
current_sequence = 0
new_lines.append(f"#EXT-X-MEDIA-SEQUENCE:{current_sequence + remove_segments}\n")
media_sequence_updated = True
continue
new_lines.append(line)
if not media_sequence_updated:
insertion_index = 0
for insertion_index, line in enumerate(new_lines):
if line.startswith("#EXTM3U"):
continue
break
new_lines.insert(insertion_index, f"#EXT-X-MEDIA-SEQUENCE:{remove_segments}\n")
with path.open("w", encoding="utf-8") as handle:
handle.writelines(new_lines)
return True, len(segments), path
def trim_m3u8_directory(
directory: str | Path,
remove_segments: int = 40,
min_segments: int = 120,
recursive: bool = True,
) -> list[tuple[bool, int, Path]]:
root = Path(directory)
pattern = "**/*.m3u8" if recursive else "*.m3u8"
results: list[tuple[bool, int, Path]] = []
for file_path in sorted(root.glob(pattern), key=lambda item: natural_sort_key(str(item))):
results.append(trim_m3u8_file(file_path, remove_segments=remove_segments, min_segments=min_segments))
return results
+20
View File
@@ -0,0 +1,20 @@
from __future__ import annotations
import argparse
from m3u_tools import shuffle_text_lines
def main() -> int:
parser = argparse.ArgumentParser(description="随机打乱文本文件行顺序")
parser.add_argument("file", nargs="?", default="pass_test.txt", help="目标文件")
parser.add_argument("--no-header", action="store_true", help="不保留第一行注释头")
args = parser.parse_args()
count, path = shuffle_text_lines(args.file, keep_comment_header=not args.no_header)
print(f"已打乱 {count} 行:{path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+14
View File
@@ -0,0 +1,14 @@
@echo off
setlocal enabledelayedexpansion
set count=1
for /f "delims=" %%i in ('dir /b /a-d') do (
if not "%%i"=="%~nx0" (
ren "%%i" "!count!%%~xi"
set /a count+=1
)
)
echo 重命名完成!
pause
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
import argparse
import threading
from m3u_tools import ProbeConfig, run_probe_file
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="M3U 链接可用性与速度检测工具",
)
parser.add_argument("-i", "--input", default="test.txt", help="输入文件,格式为 频道,URL")
parser.add_argument("-o", "--output", default="pass_test.txt", help="输出通过列表")
parser.add_argument("--workers", type=int, default=None, help="并发线程数")
parser.add_argument("--per-host", type=int, default=3, help="单主机最大并发")
parser.add_argument("--timeout", type=float, default=15.0, help="单次 HTTP 请求超时秒数")
parser.add_argument("--min-speed", type=float, default=400.0, help="最低平均速度,单位 KB/s")
parser.add_argument("--sample-bytes", type=int, default=2048, help="每个分片采样大小,单位 KB")
parser.add_argument("--sample-segments", type=int, default=3, help="每条链接抽测分片数量")
parser.add_argument("--disable-ffmpeg-fallback", action="store_true", help="禁用 FFmpeg 回退检测")
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
config = ProbeConfig(
input_file=args.input,
output_file=args.output,
max_workers=args.workers or ProbeConfig().max_workers,
per_host_limit=max(1, args.per_host),
request_timeout=max(1.0, args.timeout),
min_speed_kbps=max(1.0, args.min_speed),
sample_bytes=max(64, args.sample_bytes) * 1024,
sample_segments=max(1, args.sample_segments),
ffmpeg_fallback=not args.disable_ffmpeg_fallback,
)
def on_event(payload: dict) -> None:
kind = payload.get("kind")
if kind == "log":
line = payload.get("line")
if line:
print(line)
summary = run_probe_file(config, event_callback=on_event, stop_event=threading.Event())
print(
f"\n完成:通过 {summary.passed}/{summary.total},失败 {summary.failed}"
f"输出 {summary.output_file},报告 {summary.csv_report}"
)
return 0 if summary.failed == 0 else 1
if __name__ == "__main__":
raise SystemExit(main())
+775
View File
@@ -0,0 +1,775 @@
from __future__ import annotations
import os
import shutil
import subprocess
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Callable
from m3u_tools import (
OperationLogger,
VIDEO_FILE_EXTENSIONS,
discover_ffmpeg,
discover_ffprobe,
ensure_directory,
natural_sort_key,
slugify_filename,
subprocess_no_window_kwargs,
)
def _default_parallel_jobs() -> int:
cpu_count = os.cpu_count() or 8
# 14600K has 20 threads, RTX 5060 can handle 3 parallel NVENC encodes
return min(4, max(1, (cpu_count + 4) // 8))
def auto_rename_series(series_dir: str | Path) -> list[tuple[Path, str]]:
"""Rename all video files in a directory to sequential numbers (1.mp4, 2.mp4...).
Skips empty dirs and non-video files. Returns list of (old_path, new_name)."""
dir_path = Path(series_dir)
if not dir_path.is_dir():
return []
video_files = sorted(
[f for f in dir_path.iterdir() if f.is_file() and f.suffix.lower() in VIDEO_FILE_EXTENSIONS],
key=lambda f: natural_sort_key(f.stem),
)
if not video_files:
return []
renamed: list[tuple[Path, str]] = []
for idx, src in enumerate(video_files, start=1):
new_name = f"{idx}{src.suffix}"
dst = dir_path / new_name
if src != dst:
src.rename(dst)
renamed.append((src, new_name))
return renamed
@dataclass(slots=True)
class ConversionConfig:
root_dir: str = "."
ffmpeg_path: str = field(default_factory=discover_ffmpeg)
ffprobe_path: str = field(default_factory=discover_ffprobe)
log_dir: str = "logs"
parallel_jobs: int = field(default_factory=_default_parallel_jobs)
resolution: str = "1280:720"
video_codec: str = "hevc_nvenc"
video_bitrate: str = "0"
video_maxrate: str = "1800k"
video_bufsize: str = "3600k"
video_cq: int = 30
audio_codec: str = "aac"
audio_bitrate: str = "96k"
hls_time: int = 10
preset: str = "p3"
hwaccel: str = "cuda"
max_retries: int = 2
timeout_seconds: int = 3600
input_dir_name: str = "input"
output_dir_name: str = "output"
episode_folder_mode: bool = False
auto_rename: bool = True
@dataclass(slots=True)
class ConversionJob:
name: str
input_dir: Path
output_dir: Path
mode: str
@dataclass(slots=True)
class ConversionTask:
job_name: str
input_file: Path
output_dir: Path
playlist_file: Path
segment_prefix: str
mode: str
@dataclass(slots=True)
class ConversionResult:
job_name: str
input_file: Path
output_dir: Path
success: bool
message: str
elapsed_seconds: float
@dataclass(slots=True)
class ConversionSummary:
total: int
succeeded: int
failed: int
log_file: Path
elapsed_seconds: float
results: list[ConversionResult]
def discover_series_jobs(root_dir: str | Path, config: ConversionConfig) -> list[ConversionJob]:
"""Scan root_dir/input/ subdirs for video series.
Each subdir under input/ is treated as one series.
Output goes to root_dir/output/<series_name>/<episode>/
Falls back to legacy mode (numbered input dirs) for backward compat.
"""
root = Path(root_dir)
jobs: list[ConversionJob] = []
input_path = root / config.input_dir_name
output_path = root / config.output_dir_name
if input_path.is_dir():
for child in sorted(input_path.iterdir(), key=lambda item: natural_sort_key(item.name)):
if not child.is_dir():
continue
video_count = sum(
1 for f in child.iterdir()
if f.is_file() and f.suffix.lower() in VIDEO_FILE_EXTENSIONS
)
if video_count == 0:
continue
jobs.append(
ConversionJob(
name=child.name,
input_dir=child,
output_dir=output_path / child.name,
mode="series",
)
)
if jobs:
return jobs
# Legacy: numbered input dirs directly under root
numbered_inputs = sorted(
[path for path in root.iterdir() if path.is_dir() and path.name[:-1] == "input"],
key=lambda item: natural_sort_key(item.name),
)
for inp in numbered_inputs:
suffix = inp.name[5:] # "input" -> suffix after "input"
out = root / f"output{suffix}"
video_count = sum(
1 for f in inp.iterdir()
if f.is_file() and f.suffix.lower() in VIDEO_FILE_EXTENSIONS
)
if video_count == 0:
continue
jobs.append(
ConversionJob(name=inp.name, input_dir=inp, output_dir=out, mode="legacy")
)
if jobs:
return jobs
# Single: video files directly in root
direct_videos = [
path for path in root.iterdir()
if path.is_file() and path.suffix.lower() in VIDEO_FILE_EXTENSIONS
]
if direct_videos:
jobs.append(
ConversionJob(
name=root.name,
input_dir=root,
output_dir=output_path,
mode="single",
)
)
return jobs
def discover_single_series(root_dir: str | Path, series_name: str, config: ConversionConfig) -> ConversionJob | None:
"""Create a single ConversionJob for a specified series folder.
The series folder should be root_dir/input/<series_name>/ or an absolute path.
"""
root = Path(root_dir)
series_path = Path(series_name)
if not series_path.is_absolute():
series_path = root / config.input_dir_name / series_name
if not series_path.is_dir():
return None
video_count = sum(
1 for f in series_path.iterdir()
if f.is_file() and f.suffix.lower() in VIDEO_FILE_EXTENSIONS
)
if video_count == 0:
return None
output_path = root / config.output_dir_name / series_path.name
return ConversionJob(
name=series_path.name,
input_dir=series_path,
output_dir=output_path,
mode="series",
)
def build_conversion_tasks(jobs: list[ConversionJob], config: ConversionConfig) -> list[ConversionTask]:
tasks: list[ConversionTask] = []
for job in jobs:
audio_seen: set[str] = set()
video_files = sorted(
[item for item in job.input_dir.iterdir() if item.is_file() and item.suffix.lower() in VIDEO_FILE_EXTENSIONS],
key=lambda item: natural_sort_key(item.name),
)
for input_file in video_files:
episode_name = slugify_filename(input_file.stem)
if config.episode_folder_mode:
output_dir = job.output_dir / episode_name
playlist_file = output_dir / "index.m3u8"
else:
output_dir = job.output_dir
playlist_file = output_dir / f"{episode_name}.m3u8"
tasks.append(
ConversionTask(
job_name=job.name,
input_file=input_file,
output_dir=output_dir,
playlist_file=playlist_file,
segment_prefix=episode_name,
mode=job.mode,
)
)
return tasks
class VideoConverter:
def __init__(
self,
config: ConversionConfig,
logger: OperationLogger,
event_callback: Callable[[dict], None] | None = None,
stop_event: threading.Event | None = None,
) -> None:
self.config = config
self.logger = logger
self.event_callback = event_callback
self.stop_event = stop_event or threading.Event()
self._active_processes: list[subprocess.Popen] = []
self._process_lock = threading.Lock()
def log(self, level: str, message: str) -> None:
getattr(self.logger, level, self.logger.info)(message)
def register_process(self, process: subprocess.Popen) -> None:
with self._process_lock:
self._active_processes.append(process)
def unregister_process(self, process: subprocess.Popen) -> None:
with self._process_lock:
if process in self._active_processes:
self._active_processes.remove(process)
def terminate_active_processes(self) -> None:
with self._process_lock:
for process in self._active_processes:
try:
process.terminate()
except Exception:
pass
def build_filter(self) -> str:
return f"scale={self.config.resolution}:flags=lanczos,setsar=1:1,format=yuv420p"
def probe_duration(self, file_path: Path) -> float:
try:
cmd = [
self.config.ffprobe_path,
"-v",
"quiet",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
str(file_path),
]
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=30,
**subprocess_no_window_kwargs(),
)
if result.returncode == 0 and result.stdout.strip():
return float(result.stdout.strip())
except Exception:
pass
return 0.0
def cleanup_task_output(self, task: ConversionTask) -> None:
if task.playlist_file.exists():
task.playlist_file.unlink()
if task.output_dir.exists():
for f in list(task.output_dir.iterdir()):
if f.stem.startswith(task.segment_prefix) and f.suffix == '.ts':
f.unlink()
ensure_directory(task.output_dir)
def build_ffmpeg_command(self, task: ConversionTask) -> list[str]:
segment_template = task.output_dir / f"{task.segment_prefix}_%03d.ts"
command = [
self.config.ffmpeg_path,
"-y",
"-hide_banner",
"-loglevel",
"warning",
"-threads",
"0",
"-hwaccel",
self.config.hwaccel,
"-i",
str(task.input_file),
"-map",
"0:v:0",
"-map",
"0:a:0?",
"-sn",
"-dn",
"-map_metadata",
"-1",
"-map_chapters",
"-1",
"-vf",
self.build_filter(),
"-c:v",
self.config.video_codec,
"-preset",
self.config.preset,
"-rc",
"vbr",
"-cq",
str(self.config.video_cq),
"-b:v",
self.config.video_bitrate,
"-maxrate",
self.config.video_maxrate,
"-bufsize",
self.config.video_bufsize,
"-profile:v",
"main",
"-spatial_aq",
"1",
"-temporal_aq",
"1",
"-aq-strength",
"8",
"-rc-lookahead",
"20",
"-c:a",
self.config.audio_codec,
"-ac",
"2",
"-ar",
"48000",
"-b:a",
self.config.audio_bitrate,
"-force_key_frames",
f"expr:gte(t,n_forced*{self.config.hls_time})",
"-f",
"hls",
"-hls_time",
str(self.config.hls_time),
"-hls_playlist_type",
"vod",
"-hls_flags",
"independent_segments",
"-hls_list_size",
"0",
"-hls_segment_filename",
str(segment_template),
"-progress",
"pipe:1",
"-nostats",
str(task.playlist_file),
]
return command
def convert_task(self, task: ConversionTask) -> ConversionResult:
started = time.perf_counter()
duration_seconds = self.probe_duration(task.input_file)
last_percent = -1
last_progress_time = time.perf_counter()
for attempt in range(self.config.max_retries + 1):
if self.stop_event.is_set():
return ConversionResult(
job_name=task.job_name,
input_file=task.input_file,
output_dir=task.output_dir,
success=False,
message="任务已停止",
elapsed_seconds=time.perf_counter() - started,
)
self.cleanup_task_output(task)
command = self.build_ffmpeg_command(task)
self.log(
"info",
f"开始转码 {task.job_name} / {task.input_file.name} -> {task.playlist_file}",
)
process = subprocess.Popen(
command,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
stdin=subprocess.DEVNULL,
text=True,
encoding="utf-8",
errors="replace",
bufsize=1,
**subprocess_no_window_kwargs(),
)
self.register_process(process)
diagnostics: list[str] = []
current_out_time = 0.0
try:
while True:
if self.stop_event.is_set():
process.terminate()
raise RuntimeError("任务已停止")
line = process.stdout.readline() if process.stdout else ""
if not line and process.poll() is not None:
break
line = line.strip()
if not line:
continue
if "=" not in line:
diagnostics.append(line)
diagnostics[:] = diagnostics[-20:]
continue
key, value = line.split("=", 1)
if key == "out_time_ms":
try:
new_out = float(value) / 1_000_000.0
current_out_time = new_out
if duration_seconds > 0:
percent = min(int(new_out / duration_seconds * 100), 100)
if percent != last_percent:
last_percent = percent
if self.event_callback:
self.event_callback({
"kind": "convert_progress",
"job_name": task.job_name,
"file_name": task.input_file.name,
"percent": percent,
"out_time": new_out,
"duration": duration_seconds,
})
last_progress_time = time.perf_counter()
except ValueError:
pass
elif key == "progress" and value == "end":
# ffmpeg signals completion via progress=end
pass
elif key not in {
"frame", "fps", "stream_0_0_q", "bitrate",
"total_size", "dup_frames", "drop_frames",
"speed", "out_time", "elapsed", "progress",
}:
diagnostics.append(line)
diagnostics[:] = diagnostics[-20:]
# Stall detection: kill if no progress for 120s and encoding
if duration_seconds > 0 and current_out_time < duration_seconds:
stalled = time.perf_counter() - last_progress_time
if stalled > 120:
self.log("warning", f"FFmpeg 无进度超过 {stalled:.0f}秒,终止重试")
process.kill()
raise RuntimeError("FFmpeg 进度卡死")
process.wait()
except subprocess.TimeoutExpired:
process.kill()
process.wait()
if attempt >= self.config.max_retries:
return ConversionResult(
job_name=task.job_name,
input_file=task.input_file,
output_dir=task.output_dir,
success=False,
message="转码超时",
elapsed_seconds=time.perf_counter() - started,
)
self.log("warning", f"FFmpeg 超时,重试 {attempt+1}/{self.config.max_retries}")
time.sleep(2)
continue
except RuntimeError as error:
process.kill()
process.wait()
if attempt >= self.config.max_retries:
return ConversionResult(
job_name=task.job_name,
input_file=task.input_file,
output_dir=task.output_dir,
success=False,
message=str(error),
elapsed_seconds=time.perf_counter() - started,
)
self.log("warning", f"FFmpeg 异常: {error},重试 {attempt+1}/{self.config.max_retries}")
time.sleep(2)
continue
except Exception as error:
process.kill()
process.wait()
return ConversionResult(
job_name=task.job_name,
input_file=task.input_file,
output_dir=task.output_dir,
success=False,
message=str(error),
elapsed_seconds=time.perf_counter() - started,
)
finally:
self.unregister_process(process)
if process.returncode == 0:
return ConversionResult(
job_name=task.job_name,
input_file=task.input_file,
output_dir=task.output_dir,
success=True,
message=f"成功(CQ {self.config.video_cq}",
elapsed_seconds=time.perf_counter() - started,
)
# Diagnose failure
playlist_exists = task.playlist_file.exists()
segment_files = list(task.output_dir.glob("*.ts"))
message = f"FFmpeg 返回码 {process.returncode}"
if diagnostics:
snippet = " | ".join(diagnostics[-6:])
message += f" | {snippet}"
if not playlist_exists:
message += " | 未生成 m3u8"
elif len(segment_files) >= 2:
# Output is partial but non-empty; treat as degraded success
self.log("warning", f"FFmpeg 返回码 {process.returncode} 但已生成 {len(segment_files)} 个分片,视为部分成功")
return ConversionResult(
job_name=task.job_name,
input_file=task.input_file,
output_dir=task.output_dir,
success=True,
message=f"部分成功({len(segment_files)} 分片,返回码 {process.returncode}",
elapsed_seconds=time.perf_counter() - started,
)
if attempt >= self.config.max_retries:
return ConversionResult(
job_name=task.job_name,
input_file=task.input_file,
output_dir=task.output_dir,
success=False,
message=message,
elapsed_seconds=time.perf_counter() - started,
)
self.log("warning", f"FFmpeg 失败 {message},重试 {attempt+1}/{self.config.max_retries}")
time.sleep(2)
return ConversionResult(
job_name=task.job_name,
input_file=task.input_file,
output_dir=task.output_dir,
success=False,
message="未知错误",
elapsed_seconds=time.perf_counter() - started,
)
def run_conversion_jobs(
config: ConversionConfig,
event_callback: Callable[[dict], None] | None = None,
stop_event: threading.Event | None = None,
) -> ConversionSummary:
ensure_directory(config.log_dir)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
log_file = Path(config.log_dir) / f"convert_{timestamp}.log"
logger = OperationLogger(
log_file,
callback=lambda level, message, line: event_callback(
{"kind": "log", "level": level, "message": message, "line": line}
)
if event_callback
else None,
)
converter = VideoConverter(config=config, logger=logger, event_callback=event_callback, stop_event=stop_event)
jobs = discover_series_jobs(config.root_dir, config)
if not jobs:
logger.warning(f"没有在 {Path(config.root_dir).resolve()} 找到可处理的视频任务。")
return ConversionSummary(
total=0, succeeded=0, failed=0, log_file=log_file,
elapsed_seconds=0.0, results=[],
)
tasks = build_conversion_tasks(jobs, config)
logger.info(f"发现 {len(jobs)} 个剧集目录,待处理 {len(tasks)} 个视频,最大并发 {config.parallel_jobs}")
if not tasks:
logger.warning("未找到可转码的视频文件。")
return ConversionSummary(
total=0, succeeded=0, failed=0, log_file=log_file,
elapsed_seconds=0.0, results=[],
)
started = time.perf_counter()
results: list[ConversionResult] = []
finished = 0
with ThreadPoolExecutor(max_workers=config.parallel_jobs) as executor:
futures = {executor.submit(converter.convert_task, task): task for task in tasks}
for future in as_completed(futures):
result = future.result()
results.append(result)
finished += 1
logger.info(
f"[{'PASS' if result.success else 'FAIL'}] {finished}/{len(tasks)} "
f"{result.job_name} / {result.input_file.name} | {result.message} | {result.elapsed_seconds:.1f}s"
)
if event_callback:
event_callback({
"kind": "convert_result",
"finished": finished,
"total": len(tasks),
"result": result,
})
if stop_event and stop_event.is_set():
converter.terminate_active_processes()
elapsed_seconds = time.perf_counter() - started
succeeded = sum(1 for item in results if item.success)
failed = len(results) - succeeded
logger.info(f"转码完成:成功 {succeeded}/{len(results)},耗时 {elapsed_seconds:.1f}")
return ConversionSummary(
total=len(results), succeeded=succeeded, failed=failed,
log_file=log_file, elapsed_seconds=elapsed_seconds, results=results,
)
def run_specific_jobs(
jobs: list[ConversionJob],
config: ConversionConfig,
event_callback: Callable[[dict], None] | None = None,
stop_event: threading.Event | None = None,
) -> ConversionSummary:
ensure_directory(config.log_dir)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
log_file = Path(config.log_dir) / f"convert_{timestamp}.log"
logger = OperationLogger(
log_file,
callback=lambda level, message, line: event_callback(
{"kind": "log", "level": level, "message": message, "line": line}
)
if event_callback
else None,
)
converter = VideoConverter(config=config, logger=logger, event_callback=event_callback, stop_event=stop_event)
tasks = build_conversion_tasks(jobs, config)
logger.info(f"处理 {len(jobs)} 个剧集目录,待处理 {len(tasks)} 个视频,最大并发 {config.parallel_jobs}")
if not tasks:
logger.warning("未找到可转码的视频文件。")
return ConversionSummary(
total=0, succeeded=0, failed=0, log_file=log_file,
elapsed_seconds=0.0, results=[],
)
started = time.perf_counter()
results: list[ConversionResult] = []
finished = 0
with ThreadPoolExecutor(max_workers=config.parallel_jobs) as executor:
futures = {executor.submit(converter.convert_task, task): task for task in tasks}
for future in as_completed(futures):
result = future.result()
results.append(result)
finished += 1
logger.info(
f"[{'PASS' if result.success else 'FAIL'}] {finished}/{len(tasks)} "
f"{result.job_name} / {result.input_file.name} | {result.message} | {result.elapsed_seconds:.1f}s"
)
if event_callback:
event_callback({
"kind": "convert_result",
"finished": finished,
"total": len(tasks),
"result": result,
})
if stop_event and stop_event.is_set():
converter.terminate_active_processes()
elapsed_seconds = time.perf_counter() - started
succeeded = sum(1 for item in results if item.success)
failed = len(results) - succeeded
logger.info(f"转码完成:成功 {succeeded}/{len(results)},耗时 {elapsed_seconds:.1f}")
return ConversionSummary(
total=len(results), succeeded=succeeded, failed=failed,
log_file=log_file, elapsed_seconds=elapsed_seconds, results=results,
)
def generate_playlist_txt(
output_dir: str | Path,
url_prefix: str = "http://192.168.10.32:2088",
) -> list[Path]:
"""Scan output_dir for series folders, generate playlist txt files.
Supports both flat mode (m3u8 in series dir) and episode folder mode.
One txt per series, placed in output_dir/."""
output = Path(output_dir)
if not output.is_dir():
return []
created: list[Path] = []
for series_dir in sorted(output.iterdir(), key=lambda f: natural_sort_key(f.name)):
if not series_dir.is_dir():
continue
lines: list[str] = []
# Flat mode: m3u8 files directly in series dir
flat_m3u8 = sorted(series_dir.glob("*.m3u8"), key=lambda f: natural_sort_key(f.stem))
if flat_m3u8:
for m3u8_file in flat_m3u8:
ep_name = m3u8_file.stem
display_name = series_dir.name + ep_name
url = f"{url_prefix}/{series_dir.name}/{m3u8_file.name}"
lines.append(f"{display_name},{url}\n")
else:
# Episode folder mode: m3u8 files in subdirectories
episode_dirs = sorted(
[d for d in series_dir.iterdir() if d.is_dir()],
key=lambda d: natural_sort_key(d.name),
)
for ep_dir in episode_dirs:
m3u8_files = sorted(ep_dir.glob("*.m3u8"))
if not m3u8_files:
continue
m3u8_name = m3u8_files[0].name
display_name = series_dir.name + ep_dir.name
url = f"{url_prefix}/{series_dir.name}/{ep_dir.name}/{m3u8_name}"
lines.append(f"{display_name},{url}\n")
if not lines:
continue
txt_path = output / f"{series_dir.name}_playlist.txt"
txt_path.write_text("".join(lines), encoding="utf-8")
created.append(txt_path)
return created
+732
View File
@@ -0,0 +1,732 @@
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()