提交必要文件
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user