Files
jellytau/docs/defect-windows.md
T
dtourolle c18d79c656 fix(android): stop background audio rewinding to where it started
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
2026-08-19 17:29:31 +02:00

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 videoBitrate casing was harmless while every download was original. 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 Range header was inert for the same reason: original is the one rung served with a Content-Length and 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 PlaySessionId only 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 SubtitleStreamIndex only 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_episodes accepted a series_id from 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) and fa7cb6e9 (the local original). Both have the same parent 674c8e5c and the same diff. A merge chain pulled fa7cb6e9 and its follow-up 1e599627 into master's history during v0.5.5, so git log v0.5.4..v0.5.5 lists an autoplay fix that changed no file in that release — nextEpisodeService.ts is 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 log subjects 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:

  1. An omitted parameter is not a neutral default. SubtitleStreamIndex, AudioStreamIndex, GroupItems and MaxAudioChannels all 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.
  2. Silent binding failures. videoBitRate produced no error, no warning and a plausible-looking file. So did an unbound Range, and so did the coverage gate dividing by a stale denominator.
  3. 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.
  4. 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.