fix(downloads): classify media_kind from a file probe, not yt-dlp info

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.
This commit is contained in:
2026-08-30 03:32:14 +02:00
parent a84f2db310
commit a86131e062
6 changed files with 151 additions and 84 deletions
+42 -8
View File
@@ -7,9 +7,9 @@ guards that keep an upload out of paths that assume a re-fetchable source (edit
DB-backed (real Postgres) — skipped when no database is reachable. `settings.download_root` is
pointed at a per-test tmp dir so ingest's on-disk moves are isolated. The ingest/route tests
monkeypatch `_probe` so they don't shell out to ffprobe; the classifier's OWN parsing (image →
file, cover-art skip, dims, duration) is covered directly by `TestProbeClassifier` below, which
stubs one level down (`run_ffprobe_json`) and exercises the real `_probe`.
monkeypatch the shared `probe.probe_media` so they don't shell out to ffprobe; the classifier's OWN
parsing (image → file, cover-art skip, dims, duration) is covered directly by `TestProbeClassifier`
below, which stubs one level down (`run_ffprobe_json`) and exercises the real `probe_media`.
"""
from pathlib import Path
@@ -18,6 +18,7 @@ import pytest
from app.config import settings
from app.downloads import mediakind, quota, storage
from app.downloads import probe as probemod
from app.downloads import upload as uploadmod
from app.models import DownloadQuota, MediaAsset
@@ -42,7 +43,9 @@ def _root(tmp_path, monkeypatch):
def _probe(monkeypatch, result):
monkeypatch.setattr(uploadmod, "_probe", lambda _p: result)
# upload ingest now calls the shared `probe.probe_media` (same ground-truth ffprobe the download
# worker uses); patch it at that shared home.
monkeypatch.setattr(probemod, "probe_media", lambda _p: result)
def test_ingest_generic_file_is_download_only(db, make_user, _root, monkeypatch):
@@ -325,16 +328,16 @@ def test_ingest_cleans_up_placed_files_on_commit_failure(db, make_user, _root, m
assert db.query(MediaAsset).count() == 0 # the flushed asset rolled back — no dangling row
# --- classifier: the REAL _probe parsing (stubs ffprobe one level down) ----------------------
# The ingest/route tests above stub _probe wholesale; these exercise _probe's OWN logic — image
# --- classifier: the REAL probe_media parsing (stubs ffprobe one level down) ------------------
# The ingest/route tests above stub probe_media wholesale; these exercise its OWN logic — image
# detection, the cover-art skip, dimension/duration parsing — by faking only run_ffprobe_json, so a
# regression in the classifier the force-download guarantee leans on is actually caught.
class TestProbeClassifier:
def _probe_with(self, monkeypatch, data):
monkeypatch.setattr(uploadmod.editmod, "run_ffprobe_json", lambda *a, **k: data)
return uploadmod._probe(Path("x"))
monkeypatch.setattr(probemod.editmod, "run_ffprobe_json", lambda *a, **k: data)
return probemod.probe_media(Path("x"))
def test_probe_failure_is_a_generic_file(self, monkeypatch):
# run_ffprobe_json returns None (ffprobe failed / not media) → all None → derive() == FILE.
@@ -392,3 +395,34 @@ class TestProbeClassifier:
}
v, a, w, h, d = self._probe_with(monkeypatch, data)
assert d is None and mediakind.derive(v, a) == mediakind.AUDIO
# --- resolve_media_facts: probe-of-the-file wins over yt-dlp's info (the FB regression fix) ---------
# The download worker's finalize must classify from the ACTUAL file, not the extractor's `info` dict.
# yt-dlp omits vcodec/acodec/dims for some sources (Facebook), and deriving media_kind from those
# nulls turned a real H.264 video into a download-only 'file' (no in-app play). These pin that the
# probe is authoritative and `info` is only a gap-filler.
class TestResolveMediaFacts:
def test_probe_wins_when_info_lacks_codecs(self, monkeypatch):
# THE regression: info has no vcodec/acodec/dims (Facebook), but the file probes as h264/aac.
monkeypatch.setattr(probemod, "probe_media", lambda _p: ("h264", "aac", 720, 1280, 24))
v, a, w, h = probemod.resolve_media_facts(Path("x"), {"width": None, "height": None})
assert (v, a, w, h) == ("h264", "aac", 720, 1280)
assert mediakind.derive(v, a) == mediakind.VIDEO # NOT 'file'
def test_info_fills_only_fields_the_probe_could_not_read(self, monkeypatch):
# Probe failed / not media (all None) → fall back to info so we never regress the working path.
monkeypatch.setattr(probemod, "probe_media", lambda _p: (None, None, None, None, None))
v, a, w, h = probemod.resolve_media_facts(
Path("x"), {"vcodec": "vp9", "acodec": "opus", "width": 1920, "height": 1080}
)
assert (v, a, w, h) == ("vp9", "opus", 1920, 1080)
def test_info_none_codec_string_is_treated_as_absent(self, monkeypatch):
# yt-dlp writes the literal "none" for a missing stream — it must not become a codec name.
monkeypatch.setattr(probemod, "probe_media", lambda _p: (None, None, None, None, None))
v, a, w, h = probemod.resolve_media_facts(Path("x"), {"vcodec": "none", "acodec": "aac"})
assert (v, a) == (None, "aac")
assert mediakind.derive(v, a) == mediakind.AUDIO