A downloaded video was silently demoted to a download-only "file" (no in-app play/edit) whenever yt-dlp's info dict omitted vcodec/acodec — which it does for Facebook and other sources. Finalize derived media_kind from those nulls (`derive(None, None)` -> file), so a genuine H.264/AAC MP4 became non-playable in the library. Fix: probe the finished file on disk (the ground truth) at finalize and derive from that, falling back to the info dict only for a field the probe couldn't read. This is the same ffprobe the upload ingest already used, now extracted to a shared downloads/probe.py so the download worker and the upload path share one classifier (and can't drift). Also backfills the stored vcodec/acodec/width/height from the real file. Tests: TestResolveMediaFacts pins probe-wins-over-info (incl. the Facebook no-codec case and yt-dlp's literal "none"); upload's probe tests repoint to the shared module.
96 lines
4.3 KiB
Python
96 lines
4.3 KiB
Python
"""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 <video> player (and inline serving on the public share). Keyed on the
|
|
# container format so an animated gif/webp also lands as a generic 'file' (no browser plays those in
|
|
# a <video> either). SVG and anything else ffprobe can't decode already fails the probe → 'file'.
|
|
_IMAGE_FORMATS = {
|
|
"image2",
|
|
"image2pipe",
|
|
"png_pipe",
|
|
"mjpeg",
|
|
"mjpeg_pipe",
|
|
"jpeg_pipe",
|
|
"gif",
|
|
"webp_pipe",
|
|
"bmp_pipe",
|
|
"tiff_pipe",
|
|
"ico",
|
|
"svg_pipe",
|
|
}
|
|
|
|
|
|
def probe_media(path: Path) -> tuple[str | None, str | None, int | None, int | None, int | None]:
|
|
"""(vcodec, acodec, width, height, duration_s) from one ffprobe call; all None if ffprobe fails
|
|
or the file isn't playable media (an unprobeable file — or a still image — is treated as a generic
|
|
non-media file, so it becomes a download-only 'file' rather than a broken 'video').
|
|
|
|
A video stream flagged `attached_pic` (embedded cover art in an mp3/m4a) is skipped — it's a
|
|
thumbnail, not a real video track, so an audio file with album art still classifies as audio."""
|
|
data = editmod.run_ffprobe_json(path, show_streams=True, show_format=True)
|
|
if data is None:
|
|
return None, None, None, None, None
|
|
|
|
fmt = data.get("format") or {}
|
|
fmt_names = {n.strip() for n in (fmt.get("format_name") or "").split(",")}
|
|
if fmt_names & _IMAGE_FORMATS:
|
|
# A still image / image sequence is a generic file, not video — no vcodec means media_kind='file'.
|
|
return None, None, None, None, None
|
|
|
|
vcodec = acodec = None
|
|
width = height = None
|
|
for stream in data.get("streams", []):
|
|
kind = stream.get("codec_type")
|
|
if kind == "video" and vcodec is None:
|
|
if (stream.get("disposition") or {}).get("attached_pic"):
|
|
continue # cover art, not a video track
|
|
vcodec = stream.get("codec_name")
|
|
width = stream.get("width")
|
|
height = stream.get("height")
|
|
elif kind == "audio" and acodec is None:
|
|
acodec = stream.get("codec_name")
|
|
|
|
duration = None
|
|
try:
|
|
raw = fmt.get("duration")
|
|
if raw is not None:
|
|
duration = int(float(raw))
|
|
except (TypeError, ValueError):
|
|
duration = None
|
|
return vcodec, acodec, width, height, duration
|
|
|
|
|
|
def resolve_media_facts(
|
|
path: Path, info: dict
|
|
) -> tuple[str | None, str | None, int | None, int | None]:
|
|
"""(vcodec, acodec, width, height) for the stored download asset: PROBE the finished file on disk
|
|
(the ground truth), and fall back to the extractor's `info` dict only for a field the probe could
|
|
not read. yt-dlp's `info` omits codec/dimension fields for some sources (Facebook among them), so
|
|
deriving media_kind from it there labelled a real H.264 video a download-only 'file'. The file is
|
|
already on disk, so the probe is authoritative; `info` only fills a gap the probe left."""
|
|
pv, pa, pw, ph, _dur = probe_media(path)
|
|
info_v = info.get("vcodec") if info.get("vcodec") not in (None, "none") else None
|
|
info_a = info.get("acodec") if info.get("acodec") not in (None, "none") else None
|
|
info_w = int(info["width"]) if info.get("width") else None
|
|
info_h = int(info["height"]) if info.get("height") else None
|
|
return (
|
|
pv or info_v,
|
|
pa or info_a,
|
|
pw if pw is not None else info_w,
|
|
ph if ph is not None else info_h,
|
|
)
|