Files

217 lines
8.9 KiB
Python

"""Small Discord copies of big clips.
Discord's crawler gives a linked video about 8 seconds to download and a home upload link of ~4 MB/s means a clip
over ~30 MB never gets a player. For those, the /v/ page points Discord at a 720p copy that fits in ~22 MB
(clips over 4 minutes: 480p in ~18 MB, so they download with room to spare);
the original stays untouched. Copies are made one at a time in the background and live in WORK_DIR/previews.
"""
import hashlib
import json
import logging
import queue
import re
import shutil
import subprocess
import threading
import time
from pathlib import Path
from urllib.parse import quote
log = logging.getLogger("uvicorn.error") # uvicorn only prints its own loggers
TARGET_BYTES = 22 * 1000 * 1000
LONG_SECONDS = 240 # longer clips get a smaller, 480p copy
LONG_TARGET_BYTES = 18 * 1000 * 1000
AUDIO_KBPS = 128
MAX_VIDEO_KBPS = 4000
MIN_VIDEO_KBPS = 250 # below this a clip is too long to be worth watching in Discord; it gets no copy
KEEP_DAYS = 95 # clips delete after 90 days
KEY_RE = re.compile(r"^[0-9a-f]{24}$")
def target_bytes(duration: float) -> int:
return LONG_TARGET_BYTES if duration > LONG_SECONDS else TARGET_BYTES
def video_kbps(duration: float) -> int | None:
"""Video bitrate that makes `duration` seconds land near target_bytes(), or None when it can't sensibly fit."""
if duration <= 0:
return None
kbps = min(MAX_VIDEO_KBPS, int(target_bytes(duration) * 8 / 1000 / duration) - AUDIO_KBPS)
return kbps if kbps >= MIN_VIDEO_KBPS else None
def encode_cmd(src: str, dst: Path, kbps: int, duration: float) -> list[str]:
height = 480 if duration > LONG_SECONDS else 720
return [
"ffmpeg", "-v", "error", "-y", "-threads", "4", "-i", src,
"-map", "0:v:0", "-map", "0:a:0?", # OBS clips carry a second audio track; Discord plays one
"-vf", f"scale=-2:'min({height},ih)'", "-fpsmax", "30",
"-c:v", "libx264", "-preset", "veryfast", "-pix_fmt", "yuv420p",
"-b:v", f"{kbps}k", "-maxrate", f"{kbps * 6 // 5}k", "-bufsize", f"{kbps * 2}k",
"-c:a", "aac", "-b:a", f"{AUDIO_KBPS}k", "-ac", "2",
"-movflags", "+faststart", "-progress", "pipe:1", "-nostats", str(dst),
]
def follow_progress(lines, duration: float, report) -> None:
"""Turn ffmpeg's `-progress` lines into fractions done (0..1) for the site's upload dialog."""
for line in lines:
key, _, value = line.strip().partition("=")
if key == "out_time_us" and value.isdigit() and duration > 0:
report(min(1.0, round(int(value) / 1e6 / duration, 3)))
def duration_of(src: str) -> float:
cmd = ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "json", src]
try:
out = subprocess.run(cmd, capture_output=True, timeout=30).stdout
return float(json.loads(out)["format"]["duration"])
except (OSError, subprocess.TimeoutExpired, ValueError, KeyError):
return 0.0
def niced(cmd: list[str]) -> list[str]:
"""Run at low CPU priority where `nice` exists, so uploads and everything else on the box come first."""
return ["nice", "-n", "10", *cmd] if shutil.which("nice") else cmd
class Previews:
def __init__(self, directory: Path, zipline_url: str, over_bytes: int):
self.dir = directory
self.zipline_url = zipline_url.rstrip("/")
self.over_bytes = over_bytes
self.jobs: queue.Queue[tuple[str, int]] = queue.Queue()
self.pending: set[str] = set()
self.lock = threading.Lock()
self.worker: threading.Thread | None = None
self.current: str | None = None # key being encoded right now, and how far along it is
self.progress = 0.0
@staticmethod
def key(name: str, length: int) -> str:
# the size is part of the key so a deleted clip's name reused for another video never gets the old copy
return hashlib.sha256(f"{name}\0{length}".encode()).hexdigest()[:24]
def wanted(self, length: int) -> bool:
return length > self.over_bytes
def file(self, key: str) -> Path | None:
path = self.dir / f"{key}.mp4"
return path if KEY_RE.match(key) and path.is_file() else None
def ready(self, name: str, length: int) -> str | None:
"""The key of a finished copy, or None (asking for one in the background if it's missing)."""
key = self.key(name, length)
if self.file(key):
return key
self.request(name, length)
return None
def status(self, name: str, length: int) -> dict:
"""For the site's upload dialog. state: "small" (plays as is), "ready" (copy made), "none" (too long, no
copy) or "making" (Discord would still get the big original), with how far the encode is and whether
it's still waiting behind another clip."""
done = lambda state: {"state": state, "progress": 1.0, "queued": False}
if not self.wanted(length):
return done("small")
key = self.key(name, length)
if self.file(key):
return done("ready")
if (self.dir / f"{key}.failed").exists():
return done("none")
self.request(name, length)
with self.lock:
now = self.current == key
return {"state": "making", "progress": self.progress if now else 0.0, "queued": not now}
def renamed(self, old: str, new: str, length: int) -> None:
"""Carry a clip's copy over to its new name; without it a renamed clip's link sends Discord the big original."""
if not self.wanted(length):
return
old_key, new_key = self.key(old, length), self.key(new, length)
for ext in (".mp4", ".failed"):
try:
(self.dir / f"{old_key}{ext}").replace(self.dir / f"{new_key}{ext}")
except OSError:
pass
self.request(new, length) # still being made under the old name: start the new one now, not on the first view
def request(self, name: str, length: int) -> None:
key = self.key(name, length)
if not self.wanted(length) or self.file(key) or (self.dir / f"{key}.failed").exists():
return
with self.lock:
if key in self.pending:
return
self.pending.add(key)
self.jobs.put((name, length))
if self.worker is None or not self.worker.is_alive():
self.worker = threading.Thread(target=self.run, name="previews", daemon=True)
self.worker.start()
def run(self) -> None:
while True:
try:
name, length = self.jobs.get(timeout=60)
except queue.Empty:
with self.lock:
if self.jobs.empty():
self.worker = None
return
continue
key = self.key(name, length)
with self.lock:
self.current, self.progress = key, 0.0
try:
self.prune()
self.make(name, key)
except Exception: # one bad clip must not kill the worker
log.exception("preview for %s failed", name)
finally:
with self.lock:
self.pending.discard(key)
self.current = None
def make(self, name: str, key: str) -> None:
self.dir.mkdir(parents=True, exist_ok=True)
src = f"{self.zipline_url}/{quote(name)}"
duration = duration_of(src)
kbps = video_kbps(duration)
tmp, dst = self.dir / f"{key}.part.mp4", self.dir / f"{key}.mp4"
ok = False
if kbps:
started = time.monotonic()
try:
proc = subprocess.Popen(niced(encode_cmd(src, tmp, kbps, duration)), stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, text=True)
watchdog = threading.Timer(3 * 3600, proc.kill)
watchdog.start()
try:
follow_progress(proc.stdout, duration, self.set_progress)
code = proc.wait()
finally:
watchdog.cancel()
ok = code == 0 and tmp.is_file() and tmp.stat().st_size < TARGET_BYTES * 13 // 10
except OSError:
ok = False
log.info("preview %s for %s: %s in %.0fs", key, name, "ok" if ok else "failed", time.monotonic() - started)
if ok:
tmp.replace(dst)
else:
tmp.unlink(missing_ok=True)
(self.dir / f"{key}.failed").touch() # don't burn CPU on it again
def set_progress(self, fraction: float) -> None:
with self.lock:
self.progress = fraction
def prune(self) -> None:
cutoff = time.time() - KEEP_DAYS * 86400
for path in self.dir.glob("*"):
try:
if path.stat().st_mtime < cutoff:
path.unlink()
except OSError:
pass