- The fast join gives each ffconcat entry its own media-only format whitelist, so a library file
can no longer be opened as a nested concat list (the list's whitelist was handed down to it).
- A file the guarded probe cannot read is a download-only file, not a guess from yt-dlp's info.
- A conversion's or an ingest's commit that raises is re-read in a fresh session before any file
is removed: a commit that landed keeps the file its row now names.
- An original that cannot be removed after conversion is logged; the upload stays ready.
- An upload whose conversion is cancelled or deleted midway is freed once nothing holds it; a
paused one is kept.
- A shared link says "preparing" only while its video's job is queued or running.
- The admin storage view counts every file on disk ("Files on disk"), not only ready ones.
- A clip's failure title, unknown failure and cancel dialog, and a clip's and an upload's delete
dialog speak of what the job made; an upload's quota refusal explains that the original is kept.
- /watch announces "preparing" as an alert and moves focus to the new Retry after an action.
146 lines
7.2 KiB
Python
146 lines
7.2 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 primary_streams(data: dict) -> tuple[dict | None, dict | None]:
|
|
"""(video stream, audio stream) — the first of each kind — from a NON-None ffprobe JSON dict, by
|
|
the same reading the stored facts use: a still image / image-sequence container has neither (it
|
|
is a generic 'file'), and a video stream flagged `attached_pic` (an mp3/m4a's cover art) is not a
|
|
video track. The phone-safe rule (`playable`) judges exactly these two streams."""
|
|
fmt_names = {
|
|
n.strip() for n in ((data.get("format") or {}).get("format_name") or "").split(",")
|
|
}
|
|
if fmt_names & _IMAGE_FORMATS:
|
|
return None, None
|
|
video = audio = None
|
|
for stream in data.get("streams", []):
|
|
kind = stream.get("codec_type")
|
|
if kind == "video" and video is None:
|
|
if (stream.get("disposition") or {}).get("attached_pic"):
|
|
continue # cover art, not a video track
|
|
video = stream
|
|
elif kind == "audio" and audio is None:
|
|
audio = stream
|
|
return video, audio
|
|
|
|
|
|
def _facts_from_probe(
|
|
data: dict,
|
|
) -> tuple[str | None, str | None, int | None, int | None, int | None]:
|
|
"""(vcodec, acodec, width, height, duration_s) parsed from a NON-None ffprobe JSON dict. A still
|
|
image / image-sequence container yields no vcodec (→ generic 'file'); 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."""
|
|
fmt = data.get("format") or {}
|
|
fmt_names = {n.strip() for n in (fmt.get("format_name") or "").split(",")}
|
|
if fmt_names & _IMAGE_FORMATS:
|
|
return None, None, None, None, None
|
|
video, audio = primary_streams(data)
|
|
vcodec = video.get("codec_name") if video else None
|
|
width = video.get("width") if video else None
|
|
height = video.get("height") if video else None
|
|
acodec = audio.get("codec_name") if audio else None
|
|
|
|
duration = None
|
|
try:
|
|
raw = fmt.get("duration")
|
|
if raw is not None:
|
|
duration = int(float(raw))
|
|
# silent-ok: an unusable duration is stored as no duration, which the UI renders as unknown
|
|
# rather than as zero — the honest state, and the one a re-probe can still fill in.
|
|
except (TypeError, ValueError):
|
|
duration = None
|
|
return vcodec, acodec, width, height, duration
|
|
|
|
|
|
def probe_json(path: Path) -> dict | None:
|
|
"""The full ffprobe JSON (streams + format) of `path`, or None when ffprobe could not read it —
|
|
the one call both the stored facts (`facts`) and the phone-safe rule read from."""
|
|
return editmod.run_ffprobe_json(path, show_streams=True, show_format=True)
|
|
|
|
|
|
def facts(
|
|
data: dict | None,
|
|
) -> tuple[str | None, str | None, int | None, int | None, int | None]:
|
|
"""(vcodec, acodec, width, height, duration_s) of a `probe_json` result; all None when it is None
|
|
(an unprobeable file is treated as a generic non-media file, so it becomes a download-only 'file'
|
|
rather than a broken 'video')."""
|
|
if data is None:
|
|
return None, None, None, None, None
|
|
return _facts_from_probe(data)
|
|
|
|
|
|
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 (a still image is a generic 'file', see `primary_streams`)."""
|
|
return facts(probe_json(path))
|
|
|
|
|
|
def resolve_media_facts(
|
|
path: Path, info: dict
|
|
) -> tuple[str | None, str | None, int | None, int | None, int | None]:
|
|
"""(vcodec, acodec, width, height, duration_s) for the stored download asset: PROBE the finished
|
|
file on disk (the ground truth). 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'. It omits the duration too (a Facebook reel, an Instagram reel), and a NULL
|
|
duration hides the Library row's trim action although the file plays for minutes.
|
|
|
|
Precedence is by whether the probe RAN, not per-field: if ffprobe read the file, its codec verdict
|
|
is authoritative — INCLUDING a deliberate None (cover-art-only audio, a still image). A per-field
|
|
`probe or info` fallback would let `info`'s stale vcodec resurrect a video track the file doesn't
|
|
have (yt-dlp reports a progressive `vcodec` for an audio-mode download whose video it then strips),
|
|
mislabelling an audio download as a broken in-app 'video'. When ffprobe could NOT read the file
|
|
at all, no codec is reported: the probe runs with the media demuxer whitelist, so a file it
|
|
cannot open is one the player and the editor cannot open either — and taking `info`'s codecs
|
|
for it would make it an editable 'video' whose bytes are whatever the remote site served. The
|
|
cosmetic dimensions and the duration still gap-fill from `info` in either case."""
|
|
data = editmod.run_ffprobe_json(path, show_streams=True, show_format=True)
|
|
info_w = int(info["width"]) if info.get("width") else None
|
|
info_h = int(info["height"]) if info.get("height") else None
|
|
info_d = int(info["duration"]) if info.get("duration") else None
|
|
if data is None:
|
|
# Unreadable to the guarded probe: a download-only 'file' (no codecs → media_kind FILE).
|
|
return None, None, info_w, info_h, info_d
|
|
pv, pa, pw, ph, pd = _facts_from_probe(data)
|
|
# Probe ran → its codecs win outright (a None means "no such track", not "unknown"); info only
|
|
# gap-fills the width/height/duration the probe couldn't read.
|
|
return (
|
|
pv,
|
|
pa,
|
|
pw if pw is not None else info_w,
|
|
ph if ph is not None else info_h,
|
|
pd if pd is not None else info_d,
|
|
)
|