# 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

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:

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