Skip to content

Decide playability from the codecs, not the file extension - #304

Merged
tripsittr merged 2 commits into
mainfrom
playable-by-codec
Oct 6, 2026
Merged

tripsittr merged 2 commits into
mainfrom
playable-by-codec

Conversation

@tripsittr

Copy link
Copy Markdown
Owner

From "I need these files to play on all devices". This is the server-side half of that, and it closes a real hole.

The bug

isPlayableVideo() asked only the file extension. A .mp4 carrying HEVC video — or one whose only audio track is AC-3 — passed as playable, was sent to direct play, and died on the device while looking perfectly ordinary on the server.

An .mp4 of HEVC is as unplayable as an MKV. It just fails later and less obviously, because the container opens fine.

Audio was never checked anywhere. An AC-3 or DTS track played silently — a file that appears to work and does not, which is worse than one that plainly fails.

What it does now

Both questions, because they are separate:

  • Container — can the player open it at all? iOS cannot demux Matroska whatever is inside, so an MKV of plain H.264 is still unplayable there.
  • Codecs — can it decode what it finds? Read from the probe that already measured them.

Any one playable audio track is enough: the transcoder maps a single stream and the player picks one it can decode, so a rip carrying AC-3 and AAC plays through the AAC rather than transcoding for nothing.

Two deliberate limits

Unprobed files are still judged on the container alone. "Unknown" must not silently become "unplayable" — that would transcode the whole library until the probe caught up. library:probe runs hourly now, so this shrinks by itself.

The probe is read only when already loaded, since this runs per row on listings and a lazy read would be a query each. The three endpoints that decide playback loadMissing('probe') explicitly.

Testing

11 new tests, 372 pass across playback/streaming/media/probe. Both halves confirmed to fail when removed — extension-only judging (4 fail), ignoring audio codecs (2 fail).

Worth noting the container check exists because a test caught its absence: my first version checked codecs only, and H.264-in-MKV reported playable.

For review

The codec lists are a judgement call. PLAYABLE_VIDEO is deliberately short — h264/vp8/vp9/theora. HEVC plays on recent Apple hardware and almost nowhere else, which makes it exactly the "sometimes" that produces a black rectangle on whichever device somebody is holding. If you'd rather HEVC direct-play on Apple clients specifically, that needs per-client capability negotiation rather than one list, and I'd treat it as separate work.

The transcode command itself was already correct — it always re-encodes to H.264/AAC, so anything ffmpeg can read plays anywhere. The gap was never the encoder; it was deciding when to use it.

Not verified end-to-end. No HEVC or AC-3 file on this machine, so the codec paths are proven by fixtures using real probe JSON shapes, not by playing an actual file. Worth one check on a5 against a known HEVC rip.

isPlayableVideo() asked only the extension, so a .mp4 carrying HEVC video or
AC-3 audio passed as playable, went to direct play, and died on the device.
An .mp4 of HEVC is as unplayable as an MKV; it just fails later and less
obviously because the container opens fine.

Audio was never checked anywhere, so an AC-3 or DTS track played silently --
a file that looks like it works and does not.

Both questions are asked now and they are separate: the container decides
whether the player can open the file at all (iOS cannot demux Matroska
whatever is inside), and the codecs decide whether it can decode what it
finds. Any one playable audio track is enough, since the transcoder maps a
single stream.

An unprobed file is still judged on its container alone: "unknown" must not
become "unplayable", which would transcode the whole library until the probe
caught up. The probe is read only when already loaded, because this runs per
row on listings; the endpoints that decide playback load it explicitly.
The first version listed VP8, VP9 and Theora as playable video, Opus and
Vorbis as playable audio, and .webm/.ogv as playable containers, because a
desktop browser plays all of them.

The browser is not the client that breaks. AVFoundation opens neither WebM nor
Ogg and decodes none of those codecs, so a .webm of VP9 passed both the
container and the codec check and went to direct play -- a black rectangle on
the phone, which is the precise failure this check exists to prevent. Opus
decodes on Apple only inside a CAF container, not the mp4 a library holds, so
it would have produced silence exactly as AC-3 did.

H.264 only for video, AAC/MP3/FLAC/ALAC for audio, mp4/m4v/mov for containers.
Anything else transcodes, which always works.

Four new tests cover the cases, each confirmed to fail against the wider lists.
@tripsittr

Copy link
Copy Markdown
Owner Author

Pushed a correction before you review — the first version of this had the bug it was meant to fix.

I listed VP8, VP9 and Theora as playable video, Opus and Vorbis as playable audio, and .webm/.ogv as playable containers. All of that is true of a desktop browser and false of the client that actually breaks: AVFoundation opens neither WebM nor Ogg and decodes none of those codecs.

So a .webm of VP9 passed the container check and the codec check and went to direct play — a black rectangle on the phone, which is precisely the failure this PR exists to prevent. Opus is the same shape of error: it decodes on Apple only inside a CAF container, never the mp4 a library holds, so it would have produced silence exactly as AC-3 does.

Now: H.264 only for video, AAC/MP3/FLAC/ALAC for audio, mp4/m4v/mov for containers. Everything else transcodes, which always works. The cost of being too narrow is some wasted CPU; the cost of being too wide is a file that looks fine here and fails on the device, so the asymmetry argues for narrow.

Four tests added for the cases (webm, ogv, vp9 in mp4, opus in mp4), each confirmed to fail against the wider lists. 15 tests, 382 pass.

This is worth your eye specifically because it is a judgement call dressed as a fact — if you think HEVC should direct-play on Apple clients, that needs per-client capability negotiation rather than one global list, and I would treat it as separate work rather than widening this.

@tripsittr

Copy link
Copy Markdown
Owner Author

Verified against real files — the "not verified end-to-end" caveat in the description is now closed.

I generated four files with ffmpeg and ran the actual MediaProber over them, so these are real ffprobe results rather than the fixture JSON the tests use:

File video audio playsDirectly() isPlayableVideo()
h264_aac.mp4 h264 aac true true direct play
h264_ac3.mp4 h264 ac3 false false transcode — this is the silent-audio case
h264_aac.mkv h264 aac true false transcode — container refuses
vp9.webm vp9 — false false transcode

The MKV row is the one worth looking at: playsDirectly() says true because the codecs genuinely are fine, and isPlayableVideo() still says false because AVFoundation cannot open Matroska. That is precisely why the two checks are separate rather than one combined test, and it is the case my first version got wrong.

Still not verified: that a transcode of each actually plays on an iPhone. That needs the file on a server the phone can reach, so it remains a check for you rather than something I can close here.

@tripsittr

Copy link
Copy Markdown
Owner Author

Reviewed on a5 — clean. 15 tests pass. Based on current main, no migration, no file ops. This closes the playable-by-extension hole I flagged on the Avatar/MKV investigation — a .mp4 of HEVC (or AC-3-only audio) is no longer called direct-playable.

The codec lists are right: PLAYABLE_VIDEO = ['h264','avc1'] (HEVC excluded — Apple-only), PLAYABLE_AUDIO = ['aac','mp3','flac','alac'] (AC-3/DTS excluded). playsDirectly() returning null for unprobed is honest.

Two nuances, both non-blocking:

  1. Unprobed fallback is optimistic. isPlayableVideo() does probe?->playsDirectly() ?? true, and short-circuits to true when the probe relation isn't loaded. So an unprobed HEVC .mp4 is still reported direct-playable and will fail on-device until the hourly probe backfill (Schedule the probe so old files get measured #300, 200/hr) reaches it — the exact failure this fixes, just deferred for the existing un-backfilled library. New ingests are fine (ProbeStage probes them). If you want the playback path safe immediately, consider ?? false there (routes to HLS/transcode — plays, maybe transcodes an h264 file needlessly until probed). A deliberate perf/optimism tradeoff either way — just noting the window.
  2. The relationLoaded('probe') → true short-circuit means the answer depends on whether the caller eager-loaded probe; a path that forgets to load it gets an optimistic true. Worth confirming the actual playback-decision path (Tell native clients how to play a file they cannot read #301 StreamPolicy) eager-loads probe so the real decision is codec-based, not the listing short-circuit.

Nothing blocking.

@tripsittr
tripsittr merged commit 26ffbd1 into main Oct 6, 2026
3 checks passed
@tripsittr
tripsittr deleted the playable-by-codec branch October 6, 2026 22:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant