diff --git a/Dockerfile b/Dockerfile index 5a43df66..67eb44ae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -60,6 +60,19 @@ RUN apt-get update \ && rm /tmp/deno.zip \ && rm -rf /var/lib/apt/lists/* +# Intel NEO OpenCL runtime (intel-opencl-icd) — needed by the Plex transcode's HDR/10-bit HW path, which +# tone-maps HDR→SDR on the GPU via `tonemap_opencl` (Gen 9.5 / UHD 630 can't tonemap in the VAAPI VPP, so +# OpenCL is the only HW route; without this the 10-bit branch drops to CPU libx264). NEO was REMOVED from +# Debian trixie (this base = python:3.12-slim = trixie), so it's pulled from **bookworm** — the 22.43 there +# still supports Gen 9.5 and its .deb runs on trixie (verified live on the prod UHD 630). The bookworm apt +# source is added only for this one install and removed after, so it can't drift the rest of the image. +# Harmless on hosts with no /dev/dri or no OpenCL use (the runtime just sits unused). +RUN printf 'Types: deb\nURIs: http://deb.debian.org/debian\nSuites: bookworm\nComponents: main\nSigned-By: /usr/share/keyrings/debian-archive-keyring.pgp\n' > /etc/apt/sources.list.d/bookworm.sources \ + && apt-get update \ + && apt-get install -y --no-install-recommends -t bookworm intel-opencl-icd \ + && rm /etc/apt/sources.list.d/bookworm.sources \ + && rm -rf /var/lib/apt/lists/* + # Install from the PINNED lock (requirements.lock, generated from requirements.txt) so the image is # byte-reproducible and the exact versions are the ones pip-audit scanned in CI — not whatever floats # up from the floor ranges on build day. Regenerate the lock when requirements.txt changes (see its header). diff --git a/VERSION b/VERSION index 182dd955..b44885b0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.78.0 \ No newline at end of file +0.78.2 \ No newline at end of file diff --git a/backend/app/config.py b/backend/app/config.py index bd916a90..62acd26e 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -265,7 +265,7 @@ class Settings(BaseSettings): # --- Transcode (playable="transcode") re-encode tuning (E6 S1) --- # Hardware-encoder policy: "auto" = VAAPI (h264_vaapi) when the iGPU render node is present AND the # source codec is hardware-decodable, else libx264 software; "on" = force VAAPI wherever the render - # node exists (skip the decodability gate); "off" = always software. See plex/stream.py `_want_hw`. + # node exists (skip the decodability gate); "off" = always software. See plex/stream.py `_select_encode`. plex_transcode_hw: str = "auto" # Software-fallback libx264 tuning (IGNORED on the VAAPI path). `preset` trades CPU for size; `crf` # trades quality for size (lower = better + bigger). `max_height` caps the SOFTWARE transcode's output diff --git a/backend/app/downloads/edit.py b/backend/app/downloads/edit.py index 2ae70af6..a9028ff5 100644 --- a/backend/app/downloads/edit.py +++ b/backend/app/downloads/edit.py @@ -447,9 +447,9 @@ def run_ffprobe_json( ) -> dict | None: """One ffprobe call → its parsed JSON dict, or None on any failure (timeout, non-zero exit, missing binary, unparseable output). The SINGLE ffprobe-JSON-invocation point in the downloads - package: the browser-playability codec probe here and the upload-ingest probe (`upload._probe`) - both go through it, so the subprocess flags/timeout/error handling live in one place instead of - two drifting copies.""" + package: the browser-playability codec probe here and the shared media probe (`probe.probe_media`, + used by both the download worker's finalize and the upload ingest) both go through it, so the + subprocess flags/timeout/error handling live in one place instead of drifting copies.""" args = ["ffprobe", "-v", "error"] if show_streams: args.append("-show_streams") diff --git a/backend/app/downloads/pipeline.py b/backend/app/downloads/pipeline.py index 218f08ad..2ed78b94 100644 --- a/backend/app/downloads/pipeline.py +++ b/backend/app/downloads/pipeline.py @@ -15,7 +15,7 @@ from app.config import settings from app.db import SessionLocal from app.downloads import edit as editmod from app.downloads import errors as dlerrors -from app.downloads import adapters, credentials, formats, mediakind, service, storage +from app.downloads import adapters, credentials, formats, mediakind, probe, service, storage from app.downloads.jobdb import ( _edit_staging_dir, _job_status, @@ -475,15 +475,14 @@ def _download( asset.rel_path = rel asset.size_bytes = size asset.duration_s = meta.duration_s - asset.width = int(info["width"]) if info.get("width") else None - asset.height = int(info["height"]) if info.get("height") else None + # Codecs/dims come from a PROBE of the finished file — the ground truth — NOT yt-dlp's + # `info` dict, whose codec fields are absent for some sources (Facebook among them): + # deriving media_kind from those nulls misclassified a real H.264 video as a + # download-only 'file' with no in-app play/edit. Same probe the upload ingest uses. + asset.vcodec, asset.acodec, asset.width, asset.height = probe.resolve_media_facts( + final, info + ) asset.container = ext - asset.vcodec = ( - info.get("vcodec") if info.get("vcodec") not in (None, "none") else None - ) - asset.acodec = ( - info.get("acodec") if info.get("acodec") not in (None, "none") else None - ) asset.media_kind = mediakind.derive(asset.vcodec, asset.acodec) asset.title = meta.title asset.uploader = meta.uploader diff --git a/backend/app/downloads/probe.py b/backend/app/downloads/probe.py new file mode 100644 index 00000000..109339e6 --- /dev/null +++ b/backend/app/downloads/probe.py @@ -0,0 +1,107 @@ +"""Ground-truth media probe for a produced library file — the SINGLE place both ingest paths ask +"what is actually on disk?". + +`media_kind` (and the stored `vcodec`/`acodec`/`width`/`height`) must reflect the real file, not an +extractor's metadata: yt-dlp's `info` dict omits codec fields for some sources (e.g. Facebook), and +deriving the kind from those nulls misclassified a genuine H.264 video as a download-only `file` +(no in-app play). So the download worker (`pipeline`) and the local upload ingest (`upload`) both +probe the finished file here; `mediakind.derive` then turns the probed codecs into the kind. +""" + +from pathlib import Path + +from app.downloads import edit as editmod + +# ffprobe container formats that mean "this is a still image / image sequence", NOT playable media. +# A standalone jpg/png/gif/webp/bmp reports a real video stream (codec mjpeg/png/…) with no +# `attached_pic` flag, so without this guard it would classify as media_kind='video' and get a Play +# button + a broken in-app