A video handed off to background audio (UR-040) streams a live mp3 transcode
over plain HTTP. That response is chunked, so there is no Content-Length, and a
live encode carries no Xing header, so the extractor establishes no duration —
on device every position tick reads "<position> / 0.0".
ProgressiveMediaPeriod.configureRetry resumes a failed load in place only when
the content length is known or the seek map has a duration. With neither it
assumes the source is live, sets pendingDeferredRetry, and when the sample
queues next run dry resets them and re-requests the URL from offset 0. Our URL
carries StartTimeTicks = the handoff point, so "offset 0" is where audio-only
mode began: a transient load error armed a retry that fired minutes later, when
the buffer finally drained, and playback resumed at the handoff point and ran
on from there. A successful retry raises no error and ends nothing, so neither
arm of DR-129 was consulted and no discontinuity handler existed — the only
trace was a position that went backwards, which is why it read as random, and
why the two earlier fixes for the same symptom (DR-129's phantom end, DR-159's
relative-timeline leak) left it standing.
A retry that can only restart the stream is worth less than no retry at all.
player_retry_restarts_stream marks a Remote audio-only video item,
loadWithMetadata carries the answer to Kotlin, and the pure StreamRetryDecision
holds it for a DefaultLoadErrorHandlingPolicy that returns C.TIME_UNSET —
making onLoadError answer DONT_RETRY_FATAL before it reaches configureRetry.
The rewind becomes a recoverable error, which recoverable_error_resume already
answers by re-opening at the position playback reached, StartTimeTicks
rewritten so the selected audio track survives. Every other source keeps the
player's retry: a static file and an HLS playlist declare their timeline and
are resumed where the load stopped. onPositionDiscontinuity is added for its
log line alone, loud for DISCONTINUITY_REASON_INTERNAL, which is the rewind's
own signature.
Verified on device (FP5), same procedure both runs — handoff, 60s to fill the
buffer, a 45s radio outage:
before 13:54:52 BUFFERING, then "Media ready! Duration: -9.22e15"
(C.TIME_UNSET) and position 1165.4s -> 840.349s, exactly the handoff
base, 3.5 minutes after the outage with nothing logged between
after 14:05:08 "declining the player's retry", playback undisturbed off the
buffer for 69s (a fatal load error is only raised when the renderer
next needs data), then ERROR_CODE_IO_NETWORK_CONNECTION_FAILED ->
re-opening at 785.6s -> READY, and no rewind in the following 7 min
Kotlin tests run with ./gradlew :app:testUniversalDebugUnitTest.
TRACES: UR-040, UR-004 | DR-203 | UT-200
10 KiB
Defect windows — which bugs were present when
For each fixed defect, the releases it was actually present in. Companion to CHANGELOG.md, which says what changed; this says how long each fault had been shipping before it did.
"Present since" is the first release containing the defective code, not the first release where a user could hit it — those differ, sometimes by months, and the gap is called out where it matters. "How dated" records the evidence, so a row can be re-checked or disputed:
| Method | Meaning |
|---|---|
pickaxe |
git log -S<token> on the defective token — the commit that introduced the exact string, then the earliest tag containing it. Strongest evidence. |
feature |
The defect is inseparable from a feature that landed whole (bad rung in a new algorithm, missing caller in new plumbing), dated to that feature's release. |
absence |
The fix adds something that was never there. Dated to when the surrounding code was built, since there is no introducing commit to find. Weakest — treat as "no later than". |
Present since the first release
Nine defects date to the initial proof of concept (v0.0.1, 2026-06-23) and shipped for between two weeks and seven weeks short of two months before anyone hit them. That is the dominant pattern here: not regressions, but original assumptions that went unexercised until a later feature leaned on them.
| Defect | Present since | Fixed in | Shipped broken for | How dated |
|---|---|---|---|---|
AudioStreamIndex=0 pinned the video stream as the audio track (DR-140) |
v0.0.1 | v0.4.6 | ~7 weeks | pickaxe |
Download URL spelled videoBitrate, which Jellyfin does not bind (DR-123) |
v0.0.1 | v0.5.1 | ~7 weeks | pickaxe |
pause_download / resume_download were no-ops (DR-168) |
v0.0.1 | v0.5.3 | ~7.5 weeks | pickaxe |
.part sidecar named by with_extension, so no cleanup path matched it (DR-169) |
v0.0.1 | v0.5.3 | ~7.5 weeks | pickaxe |
Range sent on every retry regardless of the response (DR-170) |
v0.0.1 | v0.5.3 | ~7.5 weeks | pickaxe |
/Items/Latest requested with the default GroupItems=false |
v0.0.1 | v0.5.1 | ~7 weeks | pickaxe |
SubtitleStreamIndex omitted from PlaybackInfo, letting the server burn in (DR-176) |
v0.0.1 | v0.5.5 | ~8 weeks | pickaxe |
No PlaySessionId, and one hardcoded DeviceId, on every stream URL (DR-177) |
v0.0.1 | v0.5.5 | ~8 weeks | pickaxe |
download_item never recorded media_type; NULL read as 'audio' (DR-135) |
v0.0.1 | v0.4.6 | ~7 weeks | pickaxe |
download_album read its track list from the local cache (DR-173) |
v0.0.1 | v0.5.5 | ~8 weeks | pickaxe |
Device profile carried no MaxAudioChannels (DR-141) |
v0.0.1 | v0.4.6 | ~7 weeks | absence |
| Streaming ceiling fixed at 20 Mbps with no way to lower it (UR-074) | v0.0.1 | v0.5.3 (as a feature) | ~7.5 weeks | pickaxe |
Why they took so long to surface
Four of these were latent until a later feature exercised them, which is why the fix lands so far from the cause:
- The
videoBitratecasing was harmless while every download wasoriginal. It became visible only once a quality picker existed to select against — and then produced no error, just a full-size file, because Jellyfin discards an unbound query key silently. - The unconditional
Rangeheader was inert for the same reason:originalis the one rung served with aContent-Lengthand real byte-range support. It started corrupting files in v0.5.1, the moment the casing fix made transcoded downloads actually transcode. So the code dates to v0.0.1 and the corruption to v0.5.1 — a one-release window for the visible symptom. - The missing
PlaySessionIdonly bites when a stream is re-opened for the same item. Nothing re-opened one until quality switching, transcoded seek and audio-track switching existed. - The omitted
SubtitleStreamIndexonly bites on sources whose own default subtitle track is image-based, since that is what forces the server from sidecar to burn-in.
Two were masked by soft failure: the asset protocol being disabled (DR-134)
was hidden by the thumbnail cache falling back to the server copy whenever the
server was reachable, and AudioStreamIndex=0 was hidden by servers that
silently correct an out-of-range index — which is exactly why it was reported as
"some videos have no audio" rather than as a bug in the client.
Introduced by a feature, fixed later
| Defect | Present since | Fixed in | How dated |
|---|---|---|---|
| Native-path resume position never applied (both layers assumed the other seeked) | v0.0.9/v0.0.10 | v0.5.1 | feature (PlayerAdapter contract) |
get_downloaded_items matched "this library exists" rather than constraining the item to it (DR-167) |
v0.0.17 | v0.5.3 | feature (browsable downloaded library) |
SCOPE_ITEM_TYPES — the frontend/backend boundary leak (DR-063) |
v0.0.17 | v0.2.1 | pickaxe |
check:boundary anchored to the query site, blind to a named const (DR-094) |
v0.0.17 | v0.2.1 | feature (tripwire landed with the leak it missed) |
| Coverage gate divided by hardcoded denominators, reporting 158% (DR-093) | v0.0.1 | v0.2.1 | pickaxe |
| Tap deferral raced the WebView's synthesized click (DR-092 → DR-098) | v0.1.5 | v0.2.7 | feature (the deferral itself) |
Transport for webview media decided from el.paused in the DOM (DR-097) |
v0.0.9/v0.0.10 | v0.2.7 | feature (Html5PlayerAdapter) |
pick_current_episode rung 3 returned the first gap, not the furthest watched |
v0.3.0 | v0.5.1 | feature |
mirror_user_data mirrored is_favorite alone and returned early (DR-155) |
v0.4.0 | v0.5.1 | pickaxe |
| Stop-report path never fed the sync queue that existed for it (DR-154) | v0.4.6 | v0.5.1 | feature (queue + drain landed with no producer) |
| Background-audio base applied in two display-only places (DR-159) | v0.2.9 | v0.5.3 | pickaxe |
| Positions reported as 0 before the first tick, and always 0 for webview media (DR-178/179/180) | v0.5.3 | v0.5.5 | feature (DR-159's tick boundary) |
| Length-less handoff transcode left to the player's own load-error retry, which can only restart it (DR-203) | v0.0.16 | v0.8.2 | feature (the handoff's progressive-mp3 choice) |
Three of these are worth separating out, because the defect is not a mistake in the code so much as plumbing that was built and never connected:
repository_get_next_up_episodesaccepted aseries_idfrom the day it was written, and no caller passed one until v0.3.0.- The sync queue and its drain were built, tested and running in v0.4.6 with neither of its two would-be producers ever called.
- Both halves of the watched-state backend existed with no caller before v0.5.3.
An automated check cannot see any of these — the code is present, tested and reachable in principle. Only tracing a requirement to a call site catches it.
Short windows (one release or less)
| Defect | Present since | Fixed in | Note |
|---|---|---|---|
experimentalNativeVideo defaulted on, shipping audio with a blank screen (DR-161 → DR-172) |
v0.5.3 | v0.5.4 | One release. The decode path was fine; the compositing step never ran. |
| Webview-shaped audio profile insufficient — server ignores a profile's audio codec (DR-149) | v0.4.7 | v0.4.8 | The v0.4.7 fix for DR-148 was necessary and not sufficient. |
Android versionCode floor went stale (minor*100 yielding less than the 5002 already in the field) |
v0.5.0 | v0.5.3 | Caught before a broken APK shipped; no released build was un-installable. |
| Subtitle sidecar work reverted by a commit assembled from a stale tree | v0.5.5 | v0.5.5 | Never released broken — both commits are in v0.5.5. |
Fixed twice / never actually broken
-
Autoplay time reset (v0.0.2). Two commit objects carry this identical change:
dcf08f30(merged via Gitea PR #3, tagged v0.0.2) andfa7cb6e9(the local original). Both have the same parent674c8e5cand the same diff. A merge chain pulledfa7cb6e9and its follow-up1e599627into master's history during v0.5.5, sogit log v0.5.4..v0.5.5lists an autoplay fix that changed no file in that release —nextEpisodeService.tsis byte-identical across the tag boundary. The fix shipped in v0.0.2 and has not regressed.This is the one case where reading the changelog off
git logsubjects would have produced a false entry, and it is a good argument for the project's practice of deriving release notes from TRACES rather than commit subjects.
Recurring shapes
Four causes account for most of the table:
- An omitted parameter is not a neutral default.
SubtitleStreamIndex,AudioStreamIndex,GroupItemsandMaxAudioChannelsall had a server-side default that was actively wrong, and in three of the four the server's choice was more expensive than the one intended — burn-in forcing a full re-encode being the extreme case. - Silent binding failures.
videoBitRateproduced no error, no warning and a plausible-looking file. So did an unboundRange, and so did the coverage gate dividing by a stale denominator. - Two layers each assuming the other acts. Native resume (adapter recorded the position, backend never seeked), end-of-playback dispatch (two paths, one unreachable), and the surface/attach split in v0.5.0's native video.
- A guard keyed on state that moves. The tap deferral keyed suppression on a
timer handle the callback had already cleared; the HTML5 toggle keyed
play-vs-pause on
el.paused, which flips while buffering.
Reproducing this
The pickaxe rows can be re-derived directly:
git log --oneline --reverse -S'<defective token>' -- src-tauri/src # introducing commit
git tag --contains <sha> | sort -V | head -1 # first release with it
Blaming the lines a fix removed (git blame at the fix's parent) is faster to run
across many commits but was not used for the rows above: it reliably lands on
whichever commit last touched the adjacent lines, which is usually not the commit
that introduced the defect. It was used only to shortlist candidates.