提交必要文件
This commit is contained in:
+46
@@ -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
@@ -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")
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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())
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from convert import main
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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())
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user