# 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

Sixteen 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.

DR-265 is the clearest example of the "present since" / "reachable since" gap this file warns about: the `!isPlaying` gate has been there since the first commit, but nothing paused the document underneath a playing `<video>` until PiP started working on the HTML5 path in v0.5.3. Defective for ~9 weeks, hittable for ~2.

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 | | `timeupdate` gated on `!isPlaying`, so a paused activity froze the position (DR-265) | v0.0.1 | v0.11.5 | ~9 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)
Recently Added trusted the server to group new tracks — `GroupItems=true` only groups a track whose parent chain resolves a `MusicAlbum`, and older servers ignore it | v0.5.1 | v0.11.4 | feature (the v0.5.1 fix for the same symptom)
PiP and the background-audio handoff both armable, decided by one `isInPictureInPictureMode` sample (DR-266) | v0.5.3 | v0.11.5 | feature (PiP on the HTML5 path, beside a toggle that had shipped in v0.0.16)

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.