Skip to content

Tell native clients how to play a file they cannot read - #301

Merged
tripsittr merged 1 commit into
mainfrom
container-compatibility
Oct 6, 2026
Merged

tripsittr merged 1 commit into
mainfrom
container-compatibility

Conversation

@tripsittr

Copy link
Copy Markdown
Owner

From your note on #300: MKVs do not play on iOS. Server side only — the clients do not call this yet.

Why it was broken

Two things meeting:

  1. iOS cannot demux Matroska at all, whatever codec is inside. An MKV handed to AVPlayer is a black rectangle.
  2. Nothing converted them. TranscodeMediaJob writes a converted_path that playbackPath() already prefers, and MediaTranscoder::needsConversion() already detects an unplayable container — but the job was dispatched from exactly one place: a per-file button in the admin table. A library of MKVs stayed unplayable until somebody clicked each one.

The web player has asked media.hls.decide which way to play something for a long time. The apps had no equivalent, so they fetched the file and hoped.

GET /api/v1/items/{item}/playback

Reuses StreamPolicy, so one rule governs every client rather than the web and the apps drifting apart. Returns the decision, the URL, direct_playable and converting.

Asking also queues the permanent copy when the container needs one — HLS to play now, the MP4 so later plays are direct and an offline download is possible at all. Idempotent via needsConversion().

?variant=original on the stream endpoint

Per the user's call: when a download is requested on a device with a container conflict, the app should ask whether they want the original, the compatible copy, or both. This is the server half — the client can now fetch either, and direct_playable is what lets it ask honestly rather than storing a gigabyte that shows black.

Testing

12 new tests, 221 pass across Playback|Stream|Hls|Media|Api|Transcode. Three mutations confirmed to fail:

Mutation Test
conversion never queued asking_to_play_an_unplayable_file_queues_a_copy
variant ignored the_original_can_be_asked_for_by_name
ContentGate removed a_restricted_profile_is_refused

Two things worth your eye

1. The content gate filters by rating, not ownership. While testing I found the pre-existing /stream and /details endpoints both answer 200 to a different account for the same item. So this endpoint matching them is correct rather than a hole I introduced — but if a shared server is meant to scope items per account, that is a real gap and it is the same everywhere. I did not change it here; it deserves its own decision.

2. needsConversion() is called on every playback request. It touches the filesystem to check whether the converted copy exists. That is cheap per call but it is on the path of every play. If you think it belongs behind a cache, say so.

Not done

The clients do not call this yet, so the MKV is still a black rectangle on the phone until the app is taught to ask first.

MKVs do not play on iOS: AVPlayer cannot demux Matroska whatever is inside it,
and nothing converted them. TranscodeMediaJob writes a converted_path that
playbackPath() already prefers, and needsConversion() already detects an
unplayable container -- but the job was dispatched from one place only, a
per-file button in the admin table, so a library of MKVs stayed unplayable
until somebody clicked each one.

GET /api/v1/items/{item}/playback answers with the same StreamPolicy the web
player uses, so one rule governs every client, and queues the permanent copy
when the container needs one. HLS makes it play now; the MP4 makes later plays
direct and makes an offline download possible at all.

GET /api/v1/items/{item}/stream?variant=original serves the file as it sits on
disk, because both are legitimate: a phone needs the MP4, a desktop wants the
original with its full bitrate and its other audio and subtitle tracks. The
playback endpoint reports direct_playable so the app can offer the choice
honestly rather than storing a gigabyte that shows black.

The clients do not call this yet.
@tripsittr

Copy link
Copy Markdown
Owner Author

Reviewed on a5 — clean. 12 tests pass. Based on current main, no migration. This is the right server-side fix for the Avatar/MKV finding.

  • Gated: playback() does abort_unless(ContentGate::allows($item), 404) and sits under auth:sanctum — same protection as stream().
  • Decision reuses StreamPolicy::decide() (the web player's logic), returning a direct-stream URL or the HLS playlist — so iOS gets a URL it can actually play for an MKV instead of fetching a file it can't demux.
  • Background convert is safe + idempotent: queueConversionIfWorthwhile() dispatches TranscodeMediaJob only when needsConversion(), which flips false once converted/queued — no re-dispatch storms. The job writes a new converted_path; the original is untouched. The only unlink in the PR is test-fixture setup (PlaybackDecisionTest:219), not production.
  • direct_playable = isPlayableVideo() || hasConvertedCopy() — exactly the tighter playability signal I flagged; much better than filled(file_path).

Nothing blocking. Note it's server-only (clients don't call it yet), so the iOS app still needs to adopt /items/{item}/playback for the Avatar case to actually resolve on-device. (Not installed — install-on-request.)

@tripsittr
tripsittr merged commit 47b40b1 into main Oct 6, 2026
3 checks passed
@tripsittr
tripsittr deleted the container-compatibility branch October 6, 2026 21:09
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