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..dfcb0ffa --- /dev/null +++ b/backend/app/downloads/probe.py @@ -0,0 +1,95 @@ +"""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