proposals/2026-07-27-mid-song-failover.md
Rama master · réplica de lectura del repositorio original.
# Mid-song failover to another library that has the same track
**Goal** — when the library you are streaming from goes away mid-song (a revoke, a
host reboot, a dropped link) and ANOTHER paired library holds the same track, keep
the music playing instead of stopping.
**Tier** — **T2.** No wire change, no host change, no persisted field. It earns T2
on the second half only: telling the shell to re-open a track on a different host
is a new IPC event shape. The first half (continuing the same HTTP response from
another host) is invisible to everything above the shim.
---
## Why
Tim, 2026-07-26: *"if the same song exists on more than one library and one host
revokes access, the song should continue playing from a host that has not revoked
access, right?"* Reading the code says he is half-right today:
- **The NEXT track already fails over.** `trackUrl` → `routeTrack` →
`merge.bestCopy(copies, connectedLibs())` picks a copy on a still-granted host
the instant the dead library leaves `connectedLibs()`, and queue items carry
every copy, so it works even after the blend has dropped that library.
- **The CURRENT track does not.** Its URL was already resolved to the dead host
(`shim.urlForLib`), and the shim tears the response down on a mid-stream error
(`worklet/shim.js`, "so the player sees a broken stream and stops, rather than
stalling on a half-written body"). You hear the player's buffer drain, then
silence, then the next track arrives from the good host.
That was the right behaviour when a phone had exactly one library. With two, it
throws away a copy we can reach.
**This does not weaken revoke.** Host A cut us off and stays cut off - every new
read from A is denied, which is the T3 guarantee and is unchanged. The bytes come
from host B, whose grant is independent and still valid. A friend revoking their
library must not reach into a library you own; that it currently does (by stopping
your music) is the bug.
---
## Scope
### In
**1. Byte-splice, when the copies are identical.** On a mid-stream failure the shim
asks for another copy of the same track on a connected library. If that copy is
byte-identical - same `size` and same `suffix`, both already carried in
`copies[]` - it re-issues `streamTo` against the other host at
`offset + bytesWritten` and keeps writing into the SAME HTTP response. The player
never learns anything happened: same URL, same content-length, same socket.
**2. Re-resolve, when they are not.** The merge deliberately prefers a lossless
primary, so FLAC-on-one-host / MP3-on-the-other is normal and byte offsets do not
line up. The shim cannot restart a player, so the worklet emits `play:rehost` and
the shell re-resolves the track URL for the other copy and seeks to the same
TIMESTAMP. A brief gap, but the music continues.
**3. The cache sink aborts on a splice.** A write-through cache entry must never be
assembled from two sources: equal size is strong evidence, not proof, of equal
bytes, and a corrupt "complete" download is worse than no download.
### Out
- **Any change to revoke, the firewall, or the grant store.** Untouched.
- **Seeking across the splice.** A seek issues a fresh range request, which routes
normally through `routeTrack` - already correct.
- **Choosing a copy for QUALITY.** This picks the first *reachable* alternative,
the same order `bestCopy` uses. Preferring a better encode mid-song is a
different feature.
- **Failing over a cache-hit read.** A cached track needs no host at all.
- **Transcoded streams** (cellular "Auto"). They are length-unknown, non-seekable
200s with no stable byte offsets, so a splice has nothing to line up. They take
the re-resolve path.
---
## Compat
Client-only and self-contained: an unmodified host serves both halves, because
both are ordinary reads on a connection we already hold. `play:rehost` is additive;
a shell that ignores it behaves exactly as today (the track stops). Rolling back is
reverting the commit - nothing on disk or on the wire changes shape.
---
## Risks
- **A size collision.** Two different encodes that happen to be the same byte
length would splice into garbage. Vanishingly unlikely, and the mitigation is to
compare `suffix` too and to abort the cache write, so the damage is bounded to
one playback rather than a stored file.
- **Failover storms.** If the alternative host is also unreachable, the second
`streamTo` fails and we destroy the response exactly as today - one extra
attempt, not a loop. The alternative must already be in `connectedLibs()`.
- **A revoked library reappearing in copies[].** `connectedLibs()` is live link
state, so a revoked library is excluded the moment its connection dies. It can
never be chosen as the alternative.
---
## Verify
- **Gate:** `npm run verify`.
- **Unit:** `worklet/failover.js` (pure) - picks a connected non-failed copy;
reports `identical` only when size AND suffix match; returns null when the only
other copy is on a disconnected library; never returns the library that just
failed.
- **Hardware, the real scenario:** two libraries holding the same album, play a
track from library A, revoke this device on A mid-song from its dashboard.
Expect: the music continues, `shim:failover` in the log naming both libraries,
and every subsequent read denied on A. Then re-pair A.
- **Hardware, the differing-encode case:** same, with a track whose copies differ
in size - expect `play:rehost`, a short gap, and playback resuming at the same
position.
- **Regression:** revoke with only ONE library holding the track - the current
behaviour (buffer drains, playback stops) must be unchanged.