Files
imagefind/backend/imagefind/preview.py
T

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)