summaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-10-12 00:27:14 +0000
committerClaude <noreply@anthropic.com>2026-10-12 00:27:14 +0000
commitcaef63840daef2a330a698a458e3a614b3eb4a14 (patch)
tree4310a65a8c1d83957d0e8d220caf9a471311568a /docs
parentbf26b97b483bfba52d09adeb6516563830164f62 (diff)
Plan the break-kinds slice and the furniture toggle
Eight tasks covering the spec decisions end to end: the server decides a kind for every break it publishes, the phone stores it, advertising is always skipped and a show own intro, cue and credits follow one preference, and the readout names which is which. The cue prior and the furniture library are deliberately not planned here. Each is independently shippable, and the cue signature thresholds should be chosen against kinds visible in real data rather than guessed at before any exist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
Diffstat (limited to 'docs')
-rw-r--r--docs/superpowers/plans/2026-10-12-break-kinds-and-furniture-toggle.md1206
1 files changed, 1206 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-10-12-break-kinds-and-furniture-toggle.md b/docs/superpowers/plans/2026-10-12-break-kinds-and-furniture-toggle.md
new file mode 100644
index 0000000..3221434
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-12-break-kinds-and-furniture-toggle.md
@@ -0,0 +1,1206 @@
+# Break Kinds and the Furniture Toggle — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Stop reporting a show's own intro, break cue and credits as inserted advertising, and let Peter choose whether that furniture is skipped while advertising always is.
+
+**Architecture:** The server decides a kind for every break it publishes — `ad`, `intro`, `cue`, `outro` — using a rule that works before any furniture library exists: an episode whose served length equals its declared length had nothing inserted, so all its breaks are furniture and position decides which; any other episode's breaks are advertising. The phone stores the kind alongside the range and filters on it at playback according to one preference.
+
+**Tech Stack:** Go (standard library only) for the server; Kotlin, Room, DataStore Preferences, Media3 and Jetpack Compose for the app.
+
+**Spec:** `docs/superpowers/specs/2026-10-08-furniture-and-cues-design.md`
+
+## Global Constraints
+
+- Gradle is pinned at **8.14.3** because Chaquopy 16.1.0 requires it. Do not upgrade it.
+- The Go server uses the **standard library only**. No new module dependencies.
+- Room: **no `fallbackToDestructiveMigration`**. Every version gets a hand-written migration, listed one by one in `addMigrations`. Exported schemas are append-only and the pre-commit hook checks it.
+- **Coverage must not decrease.** Baseline at the time of writing: **93.68%**. The pre-commit hook fails the commit otherwise.
+- detekt and Android lint must pass. A function with more than five parameters trips detekt — bundle collaborators into a data class instead, as `SubscriptionCollaborators` and `PlaybackSources` already do.
+- **Verify from exit codes and parsed result XML, never from grepped console output.** Use `/workspace/devtools/bin/gradle-test <project-dir> [task] [-- <gradle args>]`. A green console with zero tests run, and stale result XML, have both produced wrong claims in this project. Run `head -30` on a wrapper to learn its interface.
+- The break kinds are exactly these four strings: `ad`, `intro`, `cue`, `outro`.
+- The furniture preference **defaults to on**, because that is what happens today; defaulting it off would be an unrequested behaviour change.
+- `MaxSkipFraction` (0.35) and `MaxBreakMs` (240000, applied to the matches before bridging) are unchanged by this plan.
+
+---
+
+## File Structure
+
+**Server**
+
+- `server/internal/skips/kinds.go` — *create.* `BreakKind`, the four constants, and `Classify`. Pure: no audio, no I/O, no config. Its own file because the rule is the thing most likely to be revised once the library exists, and it should be readable without reading the scan.
+- `server/internal/skips/kinds_test.go` — *create.*
+- `server/internal/skips/ads.go` — *modify.* `AdBreak` gains `Kind`; `DetectAds` classifies before returning.
+- `server/internal/store/store.go` — *modify.* `Break` gains `Kind`.
+- `server/internal/api/api.go` — *modify.* `breakRange` gains `Kind`.
+- `server/cmd/listend/main.go` — *modify.* Carries `Kind` from `AdBreak` into `store.Break`.
+
+**App**
+
+- `app/src/main/java/org/terst/listen/data/Entities.kt` — *modify.* `EpisodeMidrollEntity` gains `kind`.
+- `app/src/main/java/org/terst/listen/data/AppDatabase.kt` — *modify.* Version 13 and `MIGRATION_12_13`.
+- `app/src/main/java/org/terst/listen/data/Preferences.kt` — *modify.* `skipShowFurniture`.
+- `app/src/main/java/org/terst/listen/server/SkipServer.kt` — *modify.* `ServerBreak` gains `kind`.
+- `app/src/main/java/org/terst/listen/server/ServerSkips.kt` — *modify.* Stores the kind.
+- `app/src/main/java/org/terst/listen/playback/MidrollSkip.kt` — *modify.* Filters by kind.
+- `app/src/main/java/org/terst/listen/playback/PlaybackAdvancer.kt` — *modify.* `PlayingContext.skipFurniture`, read through `PlaybackSources`.
+- `app/src/main/java/org/terst/listen/playback/PlayheadPolicy.kt` — *modify.* Passes the flag through.
+- `app/src/main/java/org/terst/listen/fingerprint/SkipSummary.kt` — *modify.* `BreakMarker` gains a kind; the readout names it.
+- `app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt` — *modify.* Shows the kind per break.
+- `app/src/main/java/org/terst/listen/ui/SettingsScreen.kt` — *modify.* The toggle. (Confirm the filename with `grep -rln "serverUrl\|setServer" app/src/main/java/org/terst/listen/ui/` before starting Task 8; the settings screen is where `setServer` is called from.)
+
+---
+
+### Task 1: The classification rule
+
+**Files:**
+- Create: `server/internal/skips/kinds.go`
+- Create: `server/internal/skips/kinds_test.go`
+
+**Interfaces:**
+- Consumes: `AdBreak` from `server/internal/skips/ads.go` (fields `StartMs`, `EndMs`, `Partners`, `Score int64/float64`).
+- Produces: `type BreakKind string`; constants `KindAd`, `KindIntro`, `KindCue`, `KindOutro` with values `"ad"`, `"intro"`, `"cue"`, `"outro"`; `func Classify(breaks []AdBreak, servedMs, declaredMs int64) []AdBreak`.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `server/internal/skips/kinds_test.go`:
+
+```go
+package skips
+
+import "testing"
+
+/*
+What a break actually is.
+
+138 of 322 mirrored episodes have a served length equal to their declared length, which means no
+advertising was inserted into them at all -- and the scan published "ad breaks" on 111 of those,
+74 minutes of opening themes, break cues and closing credits reported as advertisements.
+
+The rule has to work before any furniture library exists, because a clip cannot be confirmed as
+the show's own until a second fetch occasion has passed. The length gap supplies that: it comes
+from the feed and the file rather than from the detector.
+*/
+
+func kindsOf(breaks []AdBreak) []BreakKind {
+ out := make([]BreakKind, 0, len(breaks))
+ for _, b := range breaks {
+ out = append(out, b.Kind)
+ }
+ return out
+}
+
+func TestAnEpisodeLongerThanDeclaredHasAdvertisingInIt(t *testing.T) {
+ // Radiolab's Grass Apes: 28:18 served against 22:05 declared.
+ got := Classify([]AdBreak{
+ {StartMs: 0, EndMs: 255_000},
+ {StartMs: 1_082_000, EndMs: 1_220_000},
+ }, 1_698_000, 1_325_000)
+
+ for i, k := range kindsOf(got) {
+ if k != KindAd {
+ t.Errorf("break %d is %q, want %q -- this episode demonstrably carries ads", i, k, KindAd)
+ }
+ }
+}
+
+func TestAnEpisodeMatchingItsDeclaredLengthIsAllFurniture(t *testing.T) {
+ // Jon Stewart, 97:03 served and declared: a 98s opening, three cues, 28s of credits.
+ got := Classify([]AdBreak{
+ {StartMs: 0, EndMs: 98_000},
+ {StartMs: 1_069_000, EndMs: 1_075_000},
+ {StartMs: 2_145_000, EndMs: 2_151_000},
+ {StartMs: 4_752_000, EndMs: 4_758_000},
+ {StartMs: 5_795_000, EndMs: 5_823_000},
+ }, 5_823_000, 5_823_000)
+
+ want := []BreakKind{KindIntro, KindCue, KindCue, KindCue, KindOutro}
+ for i, k := range kindsOf(got) {
+ if k != want[i] {
+ t.Errorf("break %d at %dms is %q, want %q", i, got[i].StartMs, k, want[i])
+ }
+ }
+}
+
+// The served file is sometimes *shorter* than declared -- Sleepy History runs about 1:36 short on
+// every episode. A feed can be wrong in that direction too, and it is still not advertising.
+func TestAnEpisodeShorterThanDeclaredIsAlsoAllFurniture(t *testing.T) {
+ got := Classify([]AdBreak{{StartMs: 2_793_000, EndMs: 2_989_000}}, 2_963_000, 3_059_000)
+
+ if got[0].Kind != KindOutro {
+ t.Errorf("kind %q, want %q -- a short file has no insertion either", got[0].Kind, KindOutro)
+ }
+}
+
+// Encoders disagree about a file's length by a fraction of a second, and that is not an advert.
+func TestASecondOfDisagreementIsNotAnAdvertisement(t *testing.T) {
+ got := Classify([]AdBreak{{StartMs: 0, EndMs: 30_000}}, 3_040_900, 3_040_000)
+
+ if got[0].Kind == KindAd {
+ t.Error("900ms of rounding was read as inserted advertising")
+ }
+}
+
+// Without a declared length there is nothing to compare against, so the safe answer is the one
+// that matches today's behaviour: treat it as advertising rather than silently reclassifying.
+func TestAnEpisodeWithNoDeclaredLengthIsTreatedAsAdvertising(t *testing.T) {
+ got := Classify([]AdBreak{{StartMs: 0, EndMs: 30_000}}, 3_040_000, 0)
+
+ if got[0].Kind != KindAd {
+ t.Errorf("kind %q, want %q when there is no gap to judge by", got[0].Kind, KindAd)
+ }
+}
+
+// A break starting inside the opening window is the intro even when it does not start at zero:
+// Search Engine's opening sits at 2:00.
+func TestABreakNearTheStartIsTheIntro(t *testing.T) {
+ got := Classify([]AdBreak{{StartMs: 120_000, EndMs: 145_000}}, 3_040_000, 3_040_000)
+
+ if got[0].Kind != KindIntro {
+ t.Errorf("kind %q, want %q", got[0].Kind, KindIntro)
+ }
+}
+
+func TestClassifyingNothingReturnsNothing(t *testing.T) {
+ if got := Classify(nil, 3_040_000, 3_040_000); len(got) != 0 {
+ t.Errorf("got %d breaks from none", len(got))
+ }
+}
+
+// Every other field has to survive, or the ranges published would be the classifier's idea of
+// them rather than the scan's.
+func TestClassifyLeavesEverythingButTheKindAlone(t *testing.T) {
+ in := AdBreak{StartMs: 1_069_000, EndMs: 1_075_000, Partners: 7, Score: 0.91}
+ got := Classify([]AdBreak{in}, 5_823_000, 5_823_000)[0]
+
+ if got.StartMs != in.StartMs || got.EndMs != in.EndMs || got.Partners != in.Partners || got.Score != in.Score {
+ t.Errorf("classify altered the range: %+v, want %+v with only Kind set", got, in)
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+```bash
+cd /workspace/listen/server && go test ./internal/skips/ -run 'Classify|Furniture|Advertis|Intro|Declared' 2>&1 | tail -20
+```
+
+Expected: a compile failure naming `Classify`, `BreakKind`, `KindAd`, `KindIntro`, `KindCue`, `KindOutro` and `AdBreak.Kind` as undefined.
+
+A compile failure is not a behavioural red. Before implementing, add the declarations as stubs that reproduce today's behaviour — `Classify` returning the breaks unchanged with `Kind` left empty — and re-run. Every test above must then fail on an assertion rather than on compilation. That is the red to work from.
+
+- [ ] **Step 3: Write the implementation**
+
+Create `server/internal/skips/kinds.go`:
+
+```go
+package skips
+
+// BreakKind says what a published break actually is, so advertising and a show's own furniture
+// can be told apart -- by the phone, which skips them on different terms, and by anyone reading
+// the accounting.
+type BreakKind string
+
+const (
+ // KindAd is advertising inserted into this copy of the episode.
+ KindAd BreakKind = "ad"
+ // KindIntro, KindCue and KindOutro are the show's own recurring audio: its opening, the
+ // music it plays at an ad break, and its closing credits.
+ KindIntro BreakKind = "intro"
+ KindCue BreakKind = "cue"
+ KindOutro BreakKind = "outro"
+)
+
+const (
+ // durationSlopMs is how far served and declared may differ before the difference is read as
+ // an insertion. Encoders disagree about a file's length by a fraction of a second.
+ durationSlopMs = 2000
+ // edgeWindowMs is how close to either end a break must sit to be the opening or the closing
+ // rather than a break cue. Two minutes: Jon Stewart's opening runs 98 seconds and Search
+ // Engine's begins at 2:00.
+ edgeWindowMs = 120_000
+)
+
+// Classify decides what each of one episode's breaks is.
+//
+// The length gap is the evidence, and the point of using it is that the detector does not
+// produce it: the feed declares the ad-free length and the file is whatever was served. An
+// episode no longer than its declared length had nothing inserted, so whatever recurs in it is
+// the show's own audio however strongly it matched.
+//
+// Everything else is called advertising. That is deliberately the unrefined answer -- an
+// ad-carrying episode also contains its own intro and cue -- but it matches what the app does
+// today, and separating the two inside such an episode needs the furniture library, which cannot
+// confirm anything until a second fetch occasion has passed.
+func Classify(breaks []AdBreak, servedMs, declaredMs int64) []AdBreak {
+ out := make([]AdBreak, 0, len(breaks))
+ inserted := servedMs > 0 && declaredMs > 0 && servedMs-declaredMs > durationSlopMs
+
+ for _, b := range breaks {
+ b.Kind = KindAd
+ if !inserted {
+ b.Kind = furnitureKindAt(b, servedMs)
+ }
+ out = append(out, b)
+ }
+ return out
+}
+
+// furnitureKindAt names a break by where it sits. Opening first: a very short episode could
+// satisfy both tests, and its first break is its opening.
+func furnitureKindAt(b AdBreak, servedMs int64) BreakKind {
+ switch {
+ case b.StartMs <= edgeWindowMs:
+ return KindIntro
+ case servedMs > 0 && servedMs-b.EndMs <= edgeWindowMs:
+ return KindOutro
+ default:
+ return KindCue
+ }
+}
+```
+
+Add the field to `AdBreak` in `server/internal/skips/ads.go`:
+
+```go
+// Kind says whether this is inserted advertising or the show's own audio. Set by Classify.
+Kind BreakKind
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+```bash
+cd /workspace/listen/server && go test ./internal/skips/ 2>&1 | tail -5
+```
+
+Expected: `ok github.com/terst/listen/server/internal/skips`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /workspace/listen && git add server/internal/skips/ && \
+git commit -m "Decide whether a break is advertising or the show's own audio
+
+An episode no longer than its declared length had nothing inserted into
+it, so whatever recurs in it is the show's furniture however strongly it
+matched. 138 of 322 mirrored episodes are in that position and the scan
+published ad breaks on 111 of them."
+```
+
+---
+
+### Task 2: Carry the kind to the phone
+
+**Files:**
+- Modify: `server/internal/skips/ads.go`
+- Modify: `server/internal/store/store.go`
+- Modify: `server/internal/api/api.go`
+- Modify: `server/cmd/listend/main.go`
+- Test: `server/internal/skips/ads_test.go`, `server/internal/api/api_test.go`
+
+**Interfaces:**
+- Consumes: `Classify`, `BreakKind` and the constants from Task 1.
+- Produces: `store.Break.Kind string` (JSON `kind`), and a `kind` field on the API's break objects.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `server/internal/skips/ads_test.go`:
+
+```go
+// The scan is where the length gap is already known, so it is where the kind is decided. Leaving
+// it to the caller would mean every caller repeating the rule.
+func TestDetectAdsNamesTheKindOfEachBreak(t *testing.T) {
+ ad := adNoise(940, 20*adRate)
+ stub := &adStub{
+ whole: map[string][]float64{
+ "a": put(adNoise(71, 600*adRate), ad, 30*adRate),
+ "b": put(adNoise(72, 600*adRate), ad, 300*adRate),
+ },
+ durations: map[string]int64{"a": 600_000, "b": 600_000},
+ }
+ // Declared equal to served: nothing was inserted, so what recurs is the show's own audio.
+ eps := []Episode{
+ {ID: "e1", Path: "a", DeclaredDurationMs: 600_000},
+ {ID: "e2", Path: "b", DeclaredDurationMs: 600_000},
+ }
+
+ got, err := DetectAds(context.Background(), stub, eps, adConfig())
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if len(got.Breaks["e1"]) == 0 {
+ t.Fatal("no breaks found")
+ }
+ if k := got.Breaks["e1"][0].Kind; k != KindIntro {
+ t.Errorf("kind %q, want %q for a break 30s into an episode with nothing inserted", k, KindIntro)
+ }
+}
+
+func TestDetectAdsCallsAnInsertedBreakAdvertising(t *testing.T) {
+ ad := adNoise(941, 20*adRate)
+ stub := &adStub{
+ whole: map[string][]float64{
+ "a": put(adNoise(73, 600*adRate), ad, 30*adRate),
+ "b": put(adNoise(74, 600*adRate), ad, 300*adRate),
+ },
+ durations: map[string]int64{"a": 600_000, "b": 600_000},
+ }
+ // Served 600s against 540s declared: a minute of advertising is in there.
+ eps := []Episode{
+ {ID: "e1", Path: "a", DeclaredDurationMs: 540_000},
+ {ID: "e2", Path: "b", DeclaredDurationMs: 540_000},
+ }
+
+ got, err := DetectAds(context.Background(), stub, eps, adConfig())
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if len(got.Breaks["e1"]) == 0 {
+ t.Fatal("no breaks found")
+ }
+ if k := got.Breaks["e1"][0].Kind; k != KindAd {
+ t.Errorf("kind %q, want %q", k, KindAd)
+ }
+}
+```
+
+For the API test, open `server/internal/api/api_test.go` and find the existing test that asserts an episode response containing breaks (search for `breaks`). Add alongside it:
+
+```go
+// The phone filters on the kind, so it has to cross the wire. Without it every break arrives
+// looking like advertising and the setting has nothing to act on.
+func TestEpisodeResponseCarriesTheKindOfEachBreak(t *testing.T) {
+ st := store.New()
+ st.Merge("https://f", "A Show", []feed.Episode{
+ {GUID: "g1", Title: "One", EnclosureURL: "https://cdn/1.mp3", DeclaredDurationMs: 600_000},
+ })
+ e := st.Match("https://f", "g1", "", "")
+ e.Path = "a.mp3"
+ e.DurationMs = 600_000
+ e.Breaks = []store.Break{{StartMs: 0, EndMs: 98_000, Kind: "intro"}}
+
+ body := getEpisodeJSON(t, st, "https://f", "g1")
+
+ if !strings.Contains(body, `"kind":"intro"`) {
+ t.Errorf("kind missing from the response: %s", body)
+ }
+}
+```
+
+`getEpisodeJSON` is a placeholder for whatever helper the existing API tests already use to issue a request and read the body — reuse that helper rather than writing a new one, and match its signature. If there is no such helper, build the request the same way the neighbouring test does.
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+```bash
+cd /workspace/listen/server && go test ./internal/skips/ ./internal/api/ 2>&1 | tail -20
+```
+
+Expected: the skips tests fail with an empty kind (`kind "", want "intro"`); the API test fails because `store.Break` has no `Kind` field.
+
+- [ ] **Step 3: Write the implementation**
+
+In `server/internal/skips/ads.go`, inside `DetectAds`, classify the merged breaks. Replace:
+
+```go
+ if len(matched) > 0 {
+ result.Breaks[clip.episode.ID] = matched
+ }
+```
+
+with:
+
+```go
+ if len(matched) > 0 {
+ result.Breaks[clip.episode.ID] = Classify(matched, clip.servedMs, clip.episode.DeclaredDurationMs)
+ }
+```
+
+In `server/internal/store/store.go`, add to `Break`:
+
+```go
+ // Kind is "ad" for inserted advertising, or "intro", "cue" or "outro" for the show's own
+ // audio. The phone skips advertising always and furniture by preference.
+ Kind string `json:"kind,omitempty"`
+```
+
+In `server/internal/api/api.go`, add to `breakRange`:
+
+```go
+ Kind string `json:"kind,omitempty"`
+```
+
+and in the loop that builds it:
+
+```go
+ breaks = append(breaks, breakRange{StartMs: br.StartMs, EndMs: br.EndMs, Kind: br.Kind})
+```
+
+In `server/cmd/listend/main.go`, in `detectAds` where `store.Break` is built:
+
+```go
+ e.Breaks = append(e.Breaks, store.Break{
+ StartMs: br.StartMs,
+ EndMs: br.EndMs,
+ Partners: br.Partners,
+ Kind: string(br.Kind),
+ })
+```
+
+- [ ] **Step 4: Run the full Go suite**
+
+```bash
+cd /workspace/listen/server && gofmt -l . && go vet ./... && go test ./... 2>&1 | grep -v "no test files"
+```
+
+Expected: no gofmt output, no vet output, every package `ok`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /workspace/listen && git add server/ && \
+git commit -m "Publish what kind each break is
+
+The phone filters on it: advertising is always skipped, the show's own
+audio by preference. Without the kind on the wire every break arrives
+looking like an advertisement."
+```
+
+---
+
+### Task 3: Store the kind on the phone
+
+**Files:**
+- Modify: `app/src/main/java/org/terst/listen/data/Entities.kt:439-444`
+- Modify: `app/src/main/java/org/terst/listen/data/AppDatabase.kt:33` (version), after line 347 (new migration), and the `addMigrations` list
+- Test: `app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt`
+
+**Interfaces:**
+- Produces: `EpisodeMidrollEntity.kind: String` defaulting to `"ad"`; database version 13; `AppDatabase.MIGRATION_12_13`.
+
+- [ ] **Step 1: Write the failing migration test**
+
+Open `app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt` and read how an existing migration is tested — follow that shape exactly rather than the sketch below. Add:
+
+```kotlin
+ /**
+ * Existing rows were written before anything knew what kind of break they were, and the only
+ * safe assumption is the one that matches old behaviour: everything was skipped, so call it
+ * advertising. The server overwrites each episode's ranges on its next download anyway.
+ */
+ @Test
+ fun migrate12To13KeepsExistingBreaksAndCallsThemAdvertising() {
+ helper.createDatabase(TEST_DB, 12).apply {
+ execSQL(
+ "INSERT INTO episodes (id, subscriptionId, title, mediaUrl) " +
+ "VALUES ('e1', 's1', 'One', 'https://cdn/1.mp3')"
+ )
+ execSQL(
+ "INSERT INTO episode_midrolls (episodeId, candidateId, startMs, endMs) " +
+ "VALUES ('e1', 'server-0', 1000, 2000)"
+ )
+ close()
+ }
+
+ val db = helper.runMigrationsAndValidate(TEST_DB, 13, true, AppDatabase.MIGRATION_12_13)
+
+ db.query("SELECT startMs, endMs, kind FROM episode_midrolls WHERE episodeId = 'e1'").use {
+ assertTrue("the break was lost", it.moveToFirst())
+ assertEquals(1000L, it.getLong(0))
+ assertEquals(2000L, it.getLong(1))
+ assertEquals("ad", it.getString(2))
+ }
+ }
+```
+
+The `INSERT INTO episodes` statement must list columns that actually exist at version 12 and satisfy its NOT NULL constraints. Read `app/schemas/org.terst.listen.data.AppDatabase/12.json` and build the statement from it; the sketch above is almost certainly missing required columns.
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+```bash
+cd /workspace/listen && /workspace/devtools/bin/gradle-test . connectedDebugAndroidTest 2>&1 | tail -12
+```
+
+Expected: FAIL — `MIGRATION_12_13` is undefined. This needs the `feedreader_test`-style emulator running; if no device is attached, start one first. If running instrumented tests is not possible, say so and do not pretend the migration is verified — an unverified Room migration has crashed this app on a real phone before.
+
+- [ ] **Step 3: Write the implementation**
+
+In `Entities.kt`, change `EpisodeMidrollEntity`:
+
+```kotlin
+data class EpisodeMidrollEntity(
+ val episodeId: String,
+ val candidateId: String,
+ val startMs: Long,
+ val endMs: Long,
+ /**
+ * "ad" for inserted advertising, or "intro", "cue" or "outro" for the show's own audio.
+ *
+ * Defaulted to "ad" rather than left null: a row written before kinds existed was skipped
+ * unconditionally, and advertising is the kind that is still skipped unconditionally, so the
+ * default preserves exactly what those rows already did.
+ */
+ @ColumnInfo(defaultValue = "ad") val kind: String = "ad",
+)
+```
+
+In `AppDatabase.kt`, change `version = 12` to `version = 13`, add after `MIGRATION_11_12`:
+
+```kotlin
+ /**
+ * Breaks gain a kind, so advertising and a show's own intro, break cue and credits can
+ * be skipped on different terms.
+ *
+ * Existing rows become "ad" because that is what they behaved as: everything in this
+ * table was skipped unconditionally before the kind existed.
+ */
+ val MIGRATION_12_13 = object : Migration(12, 13) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE `episode_midrolls` ADD COLUMN `kind` TEXT NOT NULL DEFAULT 'ad'")
+ }
+ }
+```
+
+and add `MIGRATION_12_13` to the end of the `addMigrations(...)` list.
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+```bash
+cd /workspace/listen && /workspace/devtools/bin/gradle-test . connectedDebugAndroidTest 2>&1 | tail -10
+cd /workspace/listen && /workspace/devtools/bin/gradle-test . testDebugUnitTest 2>&1 | tail -10
+```
+
+Expected: both PASS. The unit suite must still pass because `kind` has a default, so every existing `EpisodeMidrollEntity(...)` construction in tests still compiles.
+
+Confirm `app/schemas/org.terst.listen.data.AppDatabase/13.json` was generated and is staged — the pre-commit hook checks exported schemas are append-only, and a missing 13.json fails the build for the next person.
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /workspace/listen && git add app/src/main/java/org/terst/listen/data/ app/schemas/ app/src/androidTest/ && \
+git commit -m "Give a stored break its kind
+
+Existing rows become advertising, which is what they behaved as: every
+row in this table was skipped unconditionally before kinds existed."
+```
+
+---
+
+### Task 4: Receive the kind from the server
+
+**Files:**
+- Modify: `app/src/main/java/org/terst/listen/server/SkipServer.kt` (the `ServerBreak` data class)
+- Modify: `app/src/main/java/org/terst/listen/server/ServerSkips.kt:74-89` (`recordBreaks`)
+- Test: `app/src/test/java/org/terst/listen/server/SkipServerTest.kt`, and the existing `ServerSkipsTest` if there is one (`ls app/src/test/java/org/terst/listen/server/`)
+
+**Interfaces:**
+- Consumes: the server's `kind` JSON field from Task 2; `EpisodeMidrollEntity.kind` from Task 3.
+- Produces: `ServerBreak.kind: String` defaulting to `"ad"`.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `app/src/test/java/org/terst/listen/server/SkipServerTest.kt`:
+
+```kotlin
+ /**
+ * The kind is what the furniture setting acts on. A break that arrives without one has to
+ * read as advertising, because advertising is what gets skipped unconditionally -- which is
+ * also what an older server, which does not send a kind at all, used to mean.
+ */
+ @Test
+ fun `reads the kind of each break`() = runTest {
+ val transport = FakeTransport().apply {
+ response = """
+ {"status":"ready","audioUrl":"/audio/abc","detected":true,
+ "breaks":[{"startMs":0,"endMs":98000,"kind":"intro"},
+ {"startMs":900000,"endMs":1020000,"kind":"cue"}]}
+ """.trimIndent()
+ }
+
+ val got = server(transport).episode("https://f", "g1", "", "")
+
+ assertEquals("intro", got?.breaks?.get(0)?.kind)
+ assertEquals("cue", got?.breaks?.get(1)?.kind)
+ }
+
+ @Test
+ fun `a break with no kind is treated as advertising`() = runTest {
+ val transport = FakeTransport().apply {
+ response = """{"status":"ready","audioUrl":"/audio/abc","breaks":[{"startMs":0,"endMs":98000}]}"""
+ }
+
+ val got = server(transport).episode("https://f", "g1", "", "")
+
+ assertEquals("ad", got?.breaks?.single()?.kind)
+ }
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+```bash
+cd /workspace/listen && /workspace/devtools/bin/gradle-test . testDebugUnitTest -- --tests "org.terst.listen.server.SkipServerTest" 2>&1 | tail -10
+```
+
+Expected: compile failure on `kind`. Add `val kind: String = "ad"` to `ServerBreak` as a stub that is never populated from JSON, re-run, and confirm the first test fails on the assertion (`expected:<intro> but was:<ad>`) while the second passes. That is the red.
+
+- [ ] **Step 3: Write the implementation**
+
+In `SkipServer.kt`, add the field to `ServerBreak`:
+
+```kotlin
+@Serializable
+data class ServerBreak(
+ val startMs: Long,
+ val endMs: Long,
+ /**
+ * "ad", "intro", "cue" or "outro". Defaulted so a server that predates kinds still parses,
+ * and defaulted to "ad" because that is the kind skipped unconditionally -- which is what
+ * such a server meant by every break it sent.
+ */
+ val kind: String = "ad",
+)
+```
+
+(Keep the existing fields exactly as they are; only add `kind`. `kotlinx.serialization` fills the default when the field is absent, which is what makes the second test pass.)
+
+In `ServerSkips.kt`, carry it into the row:
+
+```kotlin
+ EpisodeMidrollEntity(
+ episodeId = episode.id,
+ candidateId = "server-$index",
+ startMs = range.startMs,
+ endMs = range.endMs,
+ kind = range.kind,
+ )
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+```bash
+cd /workspace/listen && /workspace/devtools/bin/gradle-test . testDebugUnitTest -- --tests "org.terst.listen.server.*" 2>&1 | tail -10
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /workspace/listen && git add app/src/main/java/org/terst/listen/server/ app/src/test/java/org/terst/listen/server/ && \
+git commit -m "Keep the kind the server sent
+
+Defaulted to advertising so a server that predates kinds still parses,
+and because that is what such a server meant by every break it sent."
+```
+
+---
+
+### Task 5: The preference
+
+**Files:**
+- Modify: `app/src/main/java/org/terst/listen/data/Preferences.kt`
+- Test: `app/src/test/java/org/terst/listen/data/PreferencesTest.kt` (check whether one exists with `ls app/src/test/java/org/terst/listen/data/`; DataStore needs a `Context`, so if the existing preferences have no unit test, they are tested elsewhere or not at all — in that case add this test to whichever instrumented or Robolectric-free harness the project already uses for `Preferences`, and if there is none, cover the default in Task 6's `PlaybackSources` test instead and note it in the commit.)
+
+**Interfaces:**
+- Produces: `Preferences.skipShowFurniture: Flow<Boolean>` defaulting to `true`; `Preferences.setSkipShowFurniture(value: Boolean)`.
+
+- [ ] **Step 1: Write the failing test**
+
+```kotlin
+ /**
+ * On by default, because it is what the app already does: every detected break has always
+ * been skipped. Defaulting it off would quietly stop skipping intros for someone who never
+ * asked for that.
+ */
+ @Test
+ fun `furniture is skipped until it is turned off`() = runTest {
+ val prefs = Preferences(context)
+
+ assertTrue(prefs.skipShowFurniture.first())
+
+ prefs.setSkipShowFurniture(false)
+ assertFalse(prefs.skipShowFurniture.first())
+ }
+```
+
+- [ ] **Step 2: Run it to verify it fails**
+
+```bash
+cd /workspace/listen && /workspace/devtools/bin/gradle-test . testDebugUnitTest -- --tests "*PreferencesTest" 2>&1 | tail -10
+```
+
+Expected: compile failure on `skipShowFurniture`.
+
+- [ ] **Step 3: Write the implementation**
+
+In `Preferences.kt`, add alongside `showPlayed`:
+
+```kotlin
+ /**
+ * Whether a show's own intro, break cue and credits are skipped as well as the advertising.
+ *
+ * On by default: every detected break has always been skipped, so this preserves that.
+ * Advertising is not covered by this and is always skipped -- it is the thing the detection
+ * exists for, and there is no case for keeping it.
+ */
+ val skipShowFurniture: Flow<Boolean> = context.store.data.map { it[SKIP_SHOW_FURNITURE] ?: true }
+
+ suspend fun setSkipShowFurniture(value: Boolean) {
+ context.store.edit { it[SKIP_SHOW_FURNITURE] = value }
+ }
+```
+
+and in the companion:
+
+```kotlin
+ val SKIP_SHOW_FURNITURE = booleanPreferencesKey("skip_show_furniture")
+```
+
+- [ ] **Step 4: Run it to verify it passes**
+
+```bash
+cd /workspace/listen && /workspace/devtools/bin/gradle-test . testDebugUnitTest -- --tests "*PreferencesTest" 2>&1 | tail -10
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /workspace/listen && git add app/src/main/java/org/terst/listen/data/Preferences.kt app/src/test/java/org/terst/listen/data/ && \
+git commit -m "Add the setting for skipping a show's own audio
+
+On by default, because skipping every detected break is what the app
+already does."
+```
+
+---
+
+### Task 6: Skip by kind
+
+**Files:**
+- Modify: `app/src/main/java/org/terst/listen/playback/MidrollSkip.kt`
+- Modify: `app/src/main/java/org/terst/listen/playback/PlaybackAdvancer.kt:13-20` (`PlayingContext`), `:29-42` (`PlaybackSources`), `:106-112` (`contextFor`)
+- Modify: `app/src/main/java/org/terst/listen/playback/PlayheadPolicy.kt:36`
+- Modify: `app/src/main/java/org/terst/listen/data/Repositories.kt` (wherever `PlaybackSources` is implemented for production — find it with `grep -rn "PlaybackSources" app/src/main/java/`)
+- Test: `app/src/test/java/org/terst/listen/playback/MidrollSkipTest.kt`, `app/src/test/java/org/terst/listen/playback/PlayheadPolicyTest.kt`
+
+**Interfaces:**
+- Consumes: `EpisodeMidrollEntity.kind` (Task 3), `Preferences.skipShowFurniture` (Task 5).
+- Produces: `MidrollSkip.skipTarget(ranges: List<EpisodeMidrollEntity>, positionMs: Long, skipFurniture: Boolean): Long?`; `PlayingContext.skipFurniture: Boolean` defaulting to `true`; `PlaybackSources.skipFurniture(): Boolean`.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `MidrollSkipTest.kt`:
+
+```kotlin
+ private fun range(startMs: Long, endMs: Long, kind: String) =
+ EpisodeMidrollEntity("e1", "server-$startMs", startMs, endMs, kind)
+
+ /** Advertising is what the detection is for; there is no case for sitting through it. */
+ @Test
+ fun `advertising is skipped even with furniture skipping off`() {
+ val ranges = listOf(range(0, 60_000, "ad"))
+
+ assertEquals(60_000L, MidrollSkip.skipTarget(ranges, 10_000, skipFurniture = false))
+ }
+
+ @Test
+ fun `a show's own audio is left alone when furniture skipping is off`() {
+ val ranges = listOf(range(0, 98_000, "intro"))
+
+ assertNull(MidrollSkip.skipTarget(ranges, 10_000, skipFurniture = false))
+ }
+
+ @Test
+ fun `a show's own audio is skipped when furniture skipping is on`() {
+ val ranges = listOf(range(0, 98_000, "intro"))
+
+ assertEquals(98_000L, MidrollSkip.skipTarget(ranges, 10_000, skipFurniture = true))
+ }
+
+ /**
+ * A break cue sits immediately before an advertisement, so with furniture skipping off the
+ * cue plays and the advertisement after it still goes -- which is a useful signal that
+ * something was cut rather than a gap in the programme.
+ */
+ @Test
+ fun `a cue is left but the advertisement after it is not`() {
+ val ranges = listOf(range(600_000, 605_000, "cue"), range(605_000, 725_000, "ad"))
+
+ assertNull(MidrollSkip.skipTarget(ranges, 601_000, skipFurniture = false))
+ assertEquals(725_000L, MidrollSkip.skipTarget(ranges, 606_000, skipFurniture = false))
+ }
+
+ /** An unknown kind from a newer server must not silently stop being skipped. */
+ @Test
+ fun `an unrecognised kind is treated as advertising`() {
+ val ranges = listOf(range(0, 60_000, "sponsorship"))
+
+ assertEquals(60_000L, MidrollSkip.skipTarget(ranges, 10_000, skipFurniture = false))
+ }
+```
+
+Append to `PlayheadPolicyTest.kt` (match the existing helpers in that file for building a `PlayingContext`):
+
+```kotlin
+ @Test
+ fun `does not seek past a show's own intro when furniture skipping is off`() {
+ val context = playingContext(
+ midrolls = listOf(EpisodeMidrollEntity("e1", "server-0", 0, 98_000, "intro")),
+ skipFurniture = false,
+ )
+
+ assertEquals(TickAction.Nothing, PlayheadPolicy.decide(context, 10_000, 600_000, progress))
+ }
+
+ @Test
+ fun `still seeks past advertising when furniture skipping is off`() {
+ val context = playingContext(
+ midrolls = listOf(EpisodeMidrollEntity("e1", "server-0", 0, 98_000, "ad")),
+ skipFurniture = false,
+ )
+
+ assertEquals(TickAction.SeekTo(98_000), PlayheadPolicy.decide(context, 10_000, 600_000, progress))
+ }
+```
+
+- [ ] **Step 2: Run them to verify they fail**
+
+```bash
+cd /workspace/listen && /workspace/devtools/bin/gradle-test . testDebugUnitTest -- --tests "org.terst.listen.playback.MidrollSkipTest" --tests "org.terst.listen.playback.PlayheadPolicyTest" 2>&1 | tail -10
+```
+
+Expected: compile failure on the new `skipFurniture` parameter. Add it with a body that ignores it — today's behaviour — re-run, and confirm the two "left alone" tests fail on their assertions while the rest pass. That is the red.
+
+- [ ] **Step 3: Write the implementation**
+
+`MidrollSkip.kt`:
+
+```kotlin
+object MidrollSkip {
+
+ /** The one kind that is skipped whatever the setting says. */
+ private const val ADVERTISING = "ad"
+
+ /**
+ * Where to jump to if the playhead has entered a break that should be skipped.
+ *
+ * Advertising always is. A show's own intro, break cue and credits are skipped only when
+ * asked for -- and anything whose kind is not recognised counts as advertising, so a kind
+ * added by a newer server does not quietly stop being skipped by an older app.
+ */
+ fun skipTarget(
+ ranges: List<EpisodeMidrollEntity>,
+ positionMs: Long,
+ skipFurniture: Boolean,
+ ): Long? =
+ ranges.asSequence()
+ .filter { skipFurniture || !isShowsOwnAudio(it.kind) }
+ .firstOrNull { positionMs >= it.startMs && positionMs < it.endMs }
+ ?.endMs
+ ?.takeIf { it > positionMs }
+
+ private fun isShowsOwnAudio(kind: String) = kind != ADVERTISING
+}
+```
+
+`PlaybackAdvancer.kt` — add to `PlayingContext`:
+
+```kotlin
+ /** Whether a show's own intro, cue and credits are skipped as well as the advertising. */
+ val skipFurniture: Boolean = true,
+```
+
+add to `PlaybackSources`:
+
+```kotlin
+ suspend fun skipFurniture(): Boolean
+```
+
+and to `PlaybackSources.None`:
+
+```kotlin
+ override suspend fun skipFurniture() = true
+```
+
+and in `contextFor`:
+
+```kotlin
+ return PlayingContext(show, chapters, episode, sources.midrolls(episode), sources.skipFurniture())
+```
+
+`PlayheadPolicy.kt` — replace the `MidrollSkip` line:
+
+```kotlin
+ // Advertising skips regardless; the show's own audio follows the setting. No per-show
+ // toggle for either: the confirmation behind a detected break is the safety mechanism.
+ MidrollSkip.skipTarget(context.midrolls, positionMs, context.skipFurniture)
+ ?.let { return TickAction.SeekTo(it) }
+```
+
+Finally implement `skipFurniture()` in the production `PlaybackSources` by reading `Preferences(context).skipShowFurniture.first()`.
+
+- [ ] **Step 4: Run the full unit suite**
+
+```bash
+cd /workspace/listen && /workspace/devtools/bin/gradle-test . testDebugUnitTest 2>&1 | tail -10
+```
+
+Expected: PASS with a test count higher than before this task.
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /workspace/listen && git add app/src/main/java/org/terst/listen/playback/ app/src/main/java/org/terst/listen/data/ app/src/test/java/org/terst/listen/playback/ && \
+git commit -m "Skip advertising always and the show's own audio by preference
+
+An unrecognised kind counts as advertising, so a kind added by a newer
+server does not quietly stop being skipped by an older app."
+```
+
+---
+
+### Task 7: Say which is which
+
+**Files:**
+- Modify: `app/src/main/java/org/terst/listen/fingerprint/SkipSummary.kt` (`BreakMarker`, `breakMarkers`, and the `EpisodeSkips` readout)
+- Modify: `app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt` (`BreakMarks`, and the break list)
+- Test: `app/src/test/java/org/terst/listen/fingerprint/SkipSummaryTest.kt`
+
+**Interfaces:**
+- Consumes: `EpisodeMidrollEntity.kind` (Task 3).
+- Produces: `SkipSummary.BreakMarker(start: Float, end: Float, kind: String)`; `SkipSummary.describeKind(kind: String): String`.
+
+- [ ] **Step 1: Write the failing tests**
+
+```kotlin
+ /**
+ * The readout is the only explanation of this feature the app ever gives, so the wording is
+ * pinned here. Calling a show's own opening "Ads inserted" is what this whole change is for
+ * -- 111 episodes said exactly that about audio no advertiser paid for.
+ */
+ @Test
+ fun `names each kind of break in words`() {
+ assertEquals("Ad break", SkipSummary.describeKind("ad"))
+ assertEquals("Show intro", SkipSummary.describeKind("intro"))
+ assertEquals("Break cue", SkipSummary.describeKind("cue"))
+ assertEquals("Credits", SkipSummary.describeKind("outro"))
+ }
+
+ /** A kind this version does not know is still skipped, so it must still have a name. */
+ @Test
+ fun `an unrecognised kind is described as an ad break`() {
+ assertEquals("Ad break", SkipSummary.describeKind("sponsorship"))
+ }
+
+ @Test
+ fun `break markers carry the kind so the scrubber can distinguish them`() {
+ val ranges = listOf(
+ EpisodeMidrollEntity("e1", "a", 0, 98_000, "intro"),
+ EpisodeMidrollEntity("e1", "b", 300_000, 420_000, "ad"),
+ )
+
+ val got = SkipSummary.breakMarkers(ranges, 600_000)
+
+ assertEquals("intro", got[0].kind)
+ assertEquals("ad", got[1].kind)
+ }
+```
+
+- [ ] **Step 2: Run them to verify they fail**
+
+```bash
+cd /workspace/listen && /workspace/devtools/bin/gradle-test . testDebugUnitTest -- --tests "*SkipSummaryTest" 2>&1 | tail -10
+```
+
+Expected: compile failure on `describeKind` and `BreakMarker.kind`. Stub `describeKind` to return `""` and add `kind` to `BreakMarker` populated with `"ad"` always, re-run, and confirm behavioural failures.
+
+- [ ] **Step 3: Write the implementation**
+
+In `SkipSummary.kt`:
+
+```kotlin
+ /** Where one detected break sits in an episode, as fractions of its length, and what it is. */
+ data class BreakMarker(val start: Float, val end: Float, val kind: String)
+```
+
+populate it in `breakMarkers`:
+
+```kotlin
+ BreakMarker(
+ start = (it.startMs.toFloat() / durationMs).coerceIn(0f, 1f),
+ end = (it.endMs.toFloat() / durationMs).coerceIn(0f, 1f),
+ kind = it.kind,
+ )
+```
+
+and add:
+
+```kotlin
+ /**
+ * What to call a break on screen.
+ *
+ * Anything unrecognised is an ad break, matching what playback does with it: a kind added by
+ * a newer server is still skipped, so it must not appear nameless.
+ */
+ fun describeKind(kind: String): String = when (kind) {
+ "intro" -> "Show intro"
+ "cue" -> "Break cue"
+ "outro" -> "Credits"
+ else -> "Ad break"
+ }
+```
+
+In `EpisodeScreen.kt`, label each listed break with `SkipSummary.describeKind(...)` and draw furniture marks in a different colour from advertising in `BreakMarks` — use `MaterialTheme.colorScheme.tertiary` for furniture against the existing colour for advertising, so the distinction survives both themes without a hand-picked alpha.
+
+- [ ] **Step 4: Run the unit suite**
+
+```bash
+cd /workspace/listen && /workspace/devtools/bin/gradle-test . testDebugUnitTest 2>&1 | tail -10
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /workspace/listen && git add app/src/main/java/org/terst/listen/fingerprint/ app/src/main/java/org/terst/listen/ui/ app/src/test/java/org/terst/listen/fingerprint/ && \
+git commit -m "Name each break instead of calling all of it advertising
+
+111 episodes reported 'Ads inserted' about a show's own opening theme and
+closing credits."
+```
+
+---
+
+### Task 8: The toggle on screen
+
+**Files:**
+- Modify: the settings screen (find it with `grep -rln "setServer" app/src/main/java/org/terst/listen/ui/`) and its view model
+- Test: the existing view-model test for that screen, if there is one
+
+**Interfaces:**
+- Consumes: `Preferences.skipShowFurniture` and `setSkipShowFurniture` (Task 5).
+
+- [ ] **Step 1: Write the failing test**
+
+Follow the shape of the existing test for that view model. The behaviour to assert is that toggling the control writes the preference and that the screen reflects what is stored:
+
+```kotlin
+ @Test
+ fun `turning off furniture skipping is remembered`() = runTest {
+ val model = settingsViewModel()
+
+ model.setSkipShowFurniture(false)
+
+ assertFalse(preferences.skipShowFurniture.first())
+ }
+```
+
+- [ ] **Step 2: Run it to verify it fails**
+
+```bash
+cd /workspace/listen && /workspace/devtools/bin/gradle-test . testDebugUnitTest -- --tests "*SettingsViewModelTest" 2>&1 | tail -10
+```
+
+Expected: compile failure on `setSkipShowFurniture`.
+
+- [ ] **Step 3: Write the implementation**
+
+Add the switch to the settings screen beneath the skip-server fields, since it is about the same feature:
+
+```kotlin
+ SettingRow(
+ title = "Skip the show's own intro and credits",
+ subtitle = "Advertising is always skipped. This covers a show's opening theme, " +
+ "its break cue and its closing credits.",
+ checked = state.skipShowFurniture,
+ onCheckedChange = viewModel::setSkipShowFurniture,
+ )
+```
+
+Use whatever row composable that screen already uses rather than introducing a new one; if it has no switch row yet, follow the pattern in `ShowSettingsSheet.kt`.
+
+- [ ] **Step 4: Verify the whole app**
+
+```bash
+cd /workspace/listen && /workspace/devtools/bin/gradle-test . testDebugUnitTest 2>&1 | tail -10
+cd /workspace/listen && ./gradlew lintDebug detekt -q 2>&1 | tail -20
+```
+
+Expected: unit tests PASS; lint and detekt clean.
+
+Then install on a device and check by hand, because a Compose switch that never reaches the preference passes every unit test:
+
+```bash
+/workspace/devtools/bin/android-launch-check .
+```
+
+Toggle it off, play an episode with a known `intro` break, and confirm the playhead stays put; toggle it on and confirm it jumps. Remember that `android-ui tap` lands on the soft keyboard if one is showing — dismiss it with `KEYCODE_BACK` (not `KEYCODE_ESCAPE`) first.
+
+- [ ] **Step 5: Commit and publish**
+
+```bash
+cd /workspace/listen && git add app/src/ && \
+git commit -m "Put the furniture toggle on screen"
+./gradlew assembleDebug -PphoneOnly -q
+/workspace/devtools/bin/publish-apk app/build/outputs/apk/debug/app-debug.apk listen-debug.apk
+```
+
+Then deploy the server, since the kinds come from it:
+
+```bash
+cd /workspace/listen/server && go build -trimpath -ldflags="-s -w" -o /tmp/listend ./cmd/listend
+systemctl stop listend.service
+install -o www-data -g www-data -m 755 /tmp/listend /site/listen.terst.org/listend
+systemctl start listend.service && sleep 15 && curl -s http://127.0.0.1:38090/api/health
+```
+
+**Episodes already examined keep their old breaks**, because detection is gated on the batch and nothing has changed about these batches. To apply kinds to the existing corpus, clear the gate once — this costs a full re-examination pass, several hours of CPU:
+
+```bash
+systemctl stop listend.service
+python3 - <<'EOF'
+import json
+p = '/site/listen.terst.org/data/state.json'
+s = json.load(open(p))
+for sh in s['shows'].values():
+ sh.pop('detectedBatch', None)
+json.dump(s, open(p, 'w'), indent=2)
+EOF
+chown www-data:www-data /site/listen.terst.org/data/state.json
+systemctl start listend.service
+```
+
+Back up `state.json` first. Afterwards, verify the headline claim rather than assuming it:
+
+```bash
+cd /site/listen.terst.org/data && python3 - <<'EOF'
+import json
+s = json.load(open('state.json'))
+wrong = 0
+for sh in s['shows'].values():
+ for e in sh['episodes']:
+ if not e.get('path'):
+ continue
+ d, v = e.get('declaredDurationMs', 0), e.get('durationMs', 0)
+ if d > 0 and v > 0 and abs(v - d) <= 2000:
+ if any(b.get('kind', 'ad') == 'ad' for b in (e.get('breaks') or [])):
+ wrong += 1
+print(f"episodes with no insertion still reporting advertising: {wrong} (want 0)")
+EOF
+```
+
+---
+
+## Self-Review
+
+**Spec coverage.** The spec's D2 is Tasks 1, 2 and 7. Its "Decisions" addendum — kinds, the always-skip rule for advertising, the default-on preference, the Room column, the readout — is Tasks 3 to 8. Its D1 (the cue prior) and D3 (the furniture library) are **deliberately not in this plan**; they are phases 2 to 4 of the spec and each is independently shippable. They need their own plans, written after this lands, because the cue signature's thresholds should be chosen against kinds visible in real data rather than guessed at now.
+
+**Placeholders.** Three steps name a file or helper to be located rather than given: the settings screen's filename (Task 8), the API test's request helper (Task 2), and the version-12 `episodes` columns for the migration test (Task 3). Each says how to find it and why it is not pinned here — the alternative is writing a path that is wrong, which is worse than a one-line lookup. Everything else carries real code.
+
+**Type consistency.** `BreakKind` is a Go type with string values; the Kotlin side uses plain `String` throughout, because Room and `kotlinx.serialization` would both need converters for an enum and the only operations are equality and display. `kind` is the field name on `store.Break`, `breakRange`, `ServerBreak`, `EpisodeMidrollEntity` and `BreakMarker`. `skipFurniture` is the parameter name in `MidrollSkip.skipTarget`, `PlayingContext` and `PlaybackSources`; `skipShowFurniture` is the preference's name — deliberately different, since one is a stored setting and the other a resolved value, and conflating them is how a Flow ends up read on the audio thread.
+
+**One risk this plan does not remove.** Task 6's production `PlaybackSources.skipFurniture()` reads a DataStore Flow with `first()` inside a suspend function called once per episode. That is correct but it is also the kind of thing that deadlocks if someone later moves it onto the ticker. The comment on `contextFor` already says these are read once per episode; keep it that way.