Files
jellytau/docs/defect-windows.md
T
dtourolle 64de22bd51 fix(player): change the audio track, and load subtitles at all
Two faults, both present since v0.0.1, both found and confirmed on a device.

Audio track (DR-258). Jellyfin builds a transcode around one AudioStreamIndex,
so the alternate tracks are not in the stream that arrives — but the native
path only ever called setAudioTrack(n), which indexes ExoPlayer's audio track
*groups*. On Android that is the common case, since any source whose default
audio codec the device cannot decode is transcoded: logcat showed ExoPlayer
holding `Audio tracks: 1` while the menu listed every track in the file, so
each selection warned `Invalid audio track index` and was dropped, leaving the
default track playing with nothing in the UI saying so.

determine_audio_track_switch_strategy now decides by whether the stream in
front of the engine carries the track at all — a direct play still selects in
place, a transcode is re-negotiated at the chosen index and resumed. Where it
resumes is the player's answer rather than the UI's: the native path has no
<video> element to read, so it sends no position, and defaulting that to zero
re-opened the film at the beginning (caught on device before it shipped).

Subtitles (DR-259). The URL was missing its `Stream.` route segment, so every
sideloaded subtitle 404ed; since media3 1.5 a sideloaded text track only
becomes a track group once its file is parsed, so 42 failed fetches left
ExoPlayer with no text tracks and selection warned `available: 0`. Verified
against a live server: the built URL answers 404, the corrected one 200. The
tests that should have caught this asserted the shape of a mock helper that
restated the format string instead of the URL the app requests — so the new
test drives the repository itself, and failed red on the old URL.
2026-08-23 19:15:05 +02:00

156 lines
10 KiB
Markdown

# Defect windows — which bugs were present when
For each fixed defect, the releases it was actually present in. Companion to
[CHANGELOG.md](../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
Fifteen defects date to the initial proof of concept (v0.0.1, 2026-06-23) and
shipped for between two weeks and 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 |
| Hero banner auto-rotation never restarted after a manual swipe (DR-038) | v0.0.1 | **v0.9.1** | ~8.5 weeks | pickaxe |
| Audio-track change asked the player to select a track the transcode never carried (DR-258) | v0.0.1 | **v0.11.1** | ~2 months | pickaxe |
| Subtitle URL missing its `Stream.` route segment, so every fetch 404ed (DR-259) | v0.0.1 | **v0.11.1** | ~2 months | 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:
```bash
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.