239 lines
8.7 KiB
Python
239 lines
8.7 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from .config import Settings
|
|
from .database import Database
|
|
from .media import MediaService
|
|
|
|
|
|
class PreviewService:
|
|
def __init__(self, db: Database, settings: Settings, media: MediaService):
|
|
self.db = db
|
|
self.settings = settings
|
|
self.media = media
|
|
self._encoders: set[str] | None = None
|
|
self._lock = threading.RLock()
|
|
self._processes: dict[str, tuple[subprocess.Popen, object]] = {}
|
|
|
|
def _video(self, video_id: str) -> dict:
|
|
with self.db.read() as conn:
|
|
row = conn.execute(
|
|
"SELECT v.*,s.kind AS source_kind FROM videos v JOIN sources s ON s.id=v.source_id "
|
|
"WHERE v.id=? AND v.available=1",
|
|
(video_id,),
|
|
).fetchone()
|
|
if not row:
|
|
raise KeyError(video_id)
|
|
return dict(row)
|
|
|
|
def _available_encoders(self) -> set[str]:
|
|
if self._encoders is None:
|
|
try:
|
|
result = subprocess.run(
|
|
[self.media.ffmpeg_path(), "-hide_banner", "-encoders"],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
self._encoders = {
|
|
line.split()[1]
|
|
for line in result.stdout.splitlines()
|
|
if line.startswith(" V") and len(line.split()) > 1
|
|
}
|
|
except (OSError, subprocess.CalledProcessError):
|
|
self._encoders = set()
|
|
return self._encoders
|
|
|
|
def _video_args(self) -> list[str]:
|
|
encoders = self._available_encoders()
|
|
if Path("/dev/dri/renderD128").exists() and "h264_vaapi" in encoders:
|
|
return [
|
|
"-vaapi_device",
|
|
"/dev/dri/renderD128",
|
|
"-vf",
|
|
"format=nv12,hwupload,scale_vaapi=w='min(1280,iw)':h=-2",
|
|
"-c:v",
|
|
"h264_vaapi",
|
|
"-b:v",
|
|
"2500k",
|
|
]
|
|
if "libopenh264" in encoders:
|
|
return ["-vf", "scale='min(1280,iw)':-2", "-c:v", "libopenh264", "-b:v", "2500k"]
|
|
if "libx264" in encoders:
|
|
return [
|
|
"-vf",
|
|
"scale='min(1280,iw)':-2",
|
|
"-c:v",
|
|
"libx264",
|
|
"-preset",
|
|
"veryfast",
|
|
"-crf",
|
|
"23",
|
|
]
|
|
raise RuntimeError("FFmpeg 缺少 h264_vaapi、libopenh264 或 libx264 编码器")
|
|
|
|
def _evict(self) -> None:
|
|
active = set(self._processes)
|
|
directories = [
|
|
path for path in self.settings.preview_dir.iterdir() if path.is_dir() and path.name not in active
|
|
]
|
|
entries = []
|
|
total = 0
|
|
for directory in directories:
|
|
files = [path for path in directory.rglob("*") if path.is_file()]
|
|
size = sum(path.stat().st_size for path in files)
|
|
modified = max((path.stat().st_mtime for path in files), default=0)
|
|
entries.append((modified, size, directory))
|
|
total += size
|
|
limit = int(self.settings.preview_cache_gb * 1024**3)
|
|
for _, size, directory in sorted(entries):
|
|
if total <= limit:
|
|
break
|
|
shutil.rmtree(directory, ignore_errors=True)
|
|
total -= size
|
|
|
|
def prepare(self, video_id: str, start_ms: int) -> str:
|
|
video = self._video(video_id)
|
|
start_seconds = max(0, start_ms // 1000 - 5)
|
|
cache_key = hashlib.sha256(
|
|
f"{video_id}:{video['fingerprint']}:{start_seconds}:continuous-v1".encode()
|
|
).hexdigest()[:32]
|
|
destination = self.settings.preview_dir / cache_key
|
|
playlist = destination / "index.m3u8"
|
|
with self._lock:
|
|
active = self._processes.get(cache_key)
|
|
if active and active[0].poll() is None and playlist.is_file():
|
|
playlist.touch()
|
|
return cache_key
|
|
if active:
|
|
try:
|
|
active[1].close()
|
|
except OSError:
|
|
pass
|
|
self._processes.pop(cache_key, None)
|
|
if playlist.is_file() and "#EXT-X-ENDLIST" in playlist.read_text(encoding="utf-8", errors="ignore"):
|
|
playlist.touch()
|
|
return cache_key
|
|
shutil.rmtree(destination, ignore_errors=True)
|
|
destination.mkdir(parents=True)
|
|
media = self.media.input_for(video)
|
|
command = [self.media.ffmpeg_path(), "-hide_banner", "-loglevel", "error", "-y", "-ss", str(start_seconds)]
|
|
command.extend(media.ffmpeg_args())
|
|
command.extend(["-map", "0:v:0", "-map", "0:a:0?"])
|
|
command.extend(self._video_args())
|
|
command.extend(
|
|
[
|
|
"-c:a",
|
|
"aac",
|
|
"-b:a",
|
|
"128k",
|
|
"-f",
|
|
"hls",
|
|
"-hls_time",
|
|
"4",
|
|
"-hls_playlist_type",
|
|
"event",
|
|
"-hls_list_size",
|
|
"0",
|
|
"-hls_flags",
|
|
"independent_segments+temp_file",
|
|
"-hls_segment_filename",
|
|
str(destination / "segment-%05d.ts"),
|
|
str(destination / "index.m3u8"),
|
|
]
|
|
)
|
|
metadata = {
|
|
"video_id": video_id,
|
|
"source_start_ms": start_seconds * 1000,
|
|
"status": "transcoding",
|
|
}
|
|
(destination / "metadata.json").write_text(json.dumps(metadata), encoding="utf-8")
|
|
log = (destination / "ffmpeg.log").open("wb")
|
|
try:
|
|
process = subprocess.Popen(
|
|
command,
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=log,
|
|
stderr=log,
|
|
preexec_fn=lambda: os.nice(10),
|
|
)
|
|
except FileNotFoundError as exc:
|
|
log.close()
|
|
raise RuntimeError("缺少 FFmpeg,无法生成兼容预览") from exc
|
|
with self._lock:
|
|
self._processes[cache_key] = (process, log)
|
|
deadline = time.monotonic() + 20
|
|
while time.monotonic() < deadline:
|
|
first_segment = destination / "segment-00000.ts"
|
|
if playlist.is_file() and first_segment.is_file() and first_segment.stat().st_size:
|
|
break
|
|
if process.poll() is not None:
|
|
log.flush()
|
|
detail = (destination / "ffmpeg.log").read_text(errors="replace")[-2000:]
|
|
with self._lock:
|
|
self._processes.pop(cache_key, None)
|
|
log.close()
|
|
raise RuntimeError(f"预览转码失败:{detail}")
|
|
time.sleep(0.1)
|
|
else:
|
|
process.terminate()
|
|
with self._lock:
|
|
self._processes.pop(cache_key, None)
|
|
log.close()
|
|
raise RuntimeError("预览转码启动超时")
|
|
self._evict()
|
|
return cache_key
|
|
|
|
def close(self) -> None:
|
|
with self._lock:
|
|
entries = list(self._processes.values())
|
|
self._processes.clear()
|
|
for process, log in entries:
|
|
if process.poll() is None:
|
|
process.terminate()
|
|
try:
|
|
process.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
process.kill()
|
|
try:
|
|
log.close()
|
|
except OSError:
|
|
pass
|
|
|
|
def remove_video(self, video_id: str) -> None:
|
|
"""Remove cached HLS previews belonging to a deleted video."""
|
|
with self._lock:
|
|
active = list(self._processes.items())
|
|
for cache_key, (process, log) in active:
|
|
metadata_path = self.settings.preview_dir / cache_key / "metadata.json"
|
|
try:
|
|
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError):
|
|
continue
|
|
if metadata.get("video_id") != video_id:
|
|
continue
|
|
if process.poll() is None:
|
|
process.terminate()
|
|
with self._lock:
|
|
self._processes.pop(cache_key, None)
|
|
try:
|
|
log.close()
|
|
except OSError:
|
|
pass
|
|
shutil.rmtree(metadata_path.parent, ignore_errors=True)
|
|
for metadata_path in self.settings.preview_dir.glob("*/metadata.json"):
|
|
try:
|
|
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError):
|
|
continue
|
|
if metadata.get("video_id") == video_id:
|
|
shutil.rmtree(metadata_path.parent, ignore_errors=True)
|