summaryrefslogtreecommitdiff
AgeCommit message (Collapse)Author
2026-10-29An episode started from the car resumes where it was leftPeter Stone
Reported as "the play button, everywhere, should resume from the listened position, not start over". Every in-app surface was already correct, checked on a device before anything was changed: the library row resumed a seeded 5:00 and played on from 5:07, the episode screen showed "7m in" and labelled its button Resume, and both go through PlaybackAdvancer, which is where this app decides where an episode starts. So the bug was not in the resume rule. Android Auto does not go through any of that. A Bluetooth button, the system's resumption and the car all reach playback through the session callback, and that path had only onAddMediaItems -- which turns a browse id into something playable and says nothing about where in it to start. The player therefore began at zero. It survived because it is wrong only where nobody developing the app presses play. onSetMediaItems now supplies the position, from PlaybackAdvancer, so the car and the phone cannot drift apart on where an episode starts. **The position is looked up from the items as they arrived, not the resolved ones.** Resolution replaces a browse id with a playable item carrying the audio URI, and the browse id does not survive it -- which is the whole reason onGetItem exists. The first version read the resolved item, got null, and resumed at zero: the same bug wearing a different hat. Found by logging it rather than by reasoning about it, after the test kept failing with the fix in place. Pinned by MediaBrowseTest over the real session protocol, because the entire defect lives in a boundary a direct call would skip. Watched failing with only the production change stashed: expected 420000, was 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-29Say what the length gap can and cannot seePeter Stone
The declared-versus-served gap bounds only the advertising a CDN inserted into this copy, which is not all the advertising in it. A show that bakes its sponsor reads into the file it publishes has no gap at all and can still be a third adverts: The 404 Media Podcast declares within 11 seconds of what it serves and carries Mack Weldon, Incogni and Warby Parker reads in the middle of it. So finding more than the gap implies is not over-reporting. The first reading of a "728%" line in the live log was that the detector had gone wrong, and it had not -- every one of those candidates is a real host-read advertisement. The readout now says which case it is instead of printing a percentage that invites the wrong conclusion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-29Find the advertising by subtracting the publisher's masterPeter Stone
Peter's idea, and it is the best detector in this project because it is a measurement rather than a guess. A publisher's transcript describes the ad-free master. Recognition over the mirrored file describes the copy the phone will play. The copy is the master with advertising stitched into it, so the text present in the served copy and absent from the master *is* the advertising, with its own boundaries. No phrase list to be wrong about, and no show whose hosts happen to talk like an advert. Measured on Darknet Diaries' "Conti" before it was wired in. The feed declares 59:17, the file served runs 63:33, so 4:15 was inserted. Subtraction found **4:20 in three pods -- 102%** against a figure no detector produced. Reading the ad copy on the same words found 0:16, or 6%. Every pod starts on the first word of an ad read and ends on the last: a NetSuite pre-roll, a 2:20 midroll, and a Doppel read at 32:08. Nothing of the programme is clipped. The alignment has to survive recognition being approximate -- whisper writes "Bombus" for "Bombas" and spells numbers differently -- so it anchors on runs of five words occurring exactly once in each text. A window containing a mistake simply fails to anchor and its neighbours carry the alignment instead. Uniqueness is what makes an anchor safe: a phrase appearing twice in the master cannot say which occurrence it matches, and a wrong anchor drags the alignment across a break. Inside an advertisement nothing matches at all, which is exactly what makes a pod show up as a gap. An anchor names the *start* of a matching run, so the content between two anchors begins after the first run ends. Getting that wrong put every break one anchor-length early, which on a real episode is a skip over the last sentence of the programme. The tests pinned it. **This is why a declined transcript is now kept.** Declined means "this copy carries inserted advertising the master does not describe" -- precisely the episode subtraction works on. One declined for describing a different recording is still thrown away, since it is no use for anything, and Fits now carries a reason rather than making callers match on its wording. The nineteen episodes declined before this existed fetch their masters once on the next poll. Nothing is published as a skip yet. The candidates are stored with the words and dimmed on the transcript screen, and every episode now logs what was found against what the lengths imply. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-29Recognise the words in episodes no publisher transcript can describePeter Stone
whisper.cpp, installed on tungsten and shelled out to per episode. The other 304 of 342 mirrored episodes -- the ones whose publisher's transcript was declined because this copy carries inserted advertising the master does not describe, which is both why it is unusable and why having words would be worth most. **Measured, and an order of magnitude cheaper than I told Peter.** base.en on six threads does 10.3x realtime on this box: a 63-minute Darknet Diaries episode took 6m11s at 793MB peak. The earlier "roughly realtime, days of backfill" was wrong. The real backlog is 298 episodes, 266 hours of audio, about 26 hours of CPU -- three days at 45 minutes per two-hour cycle, which is the default budget. Bounded per cycle rather than exhaustive, and only on the timer: a wakeup is the phone pushing a subscription list and wanting that show polled now, and following it with three quarters of an hour of recognition would make a new subscription feel broken. Newest first, because an episode about to be played is worth having words for before one from the back of a catalogue. **The first real run found two bugs the tests could not have.** The working directory was never created, so ffmpeg could not write and every episode failed -- and each failure was recorded as "do not try this one again". A misconfigured deploy would have marked the whole backlog attempted in seconds and nothing would ever have been transcribed, which is precisely the shape of the midroll scan marking episodes checked forever after an OutOfMemoryError nobody saw. transcribe.Breaker now abandons a pass after three consecutive failures and gives those attempts back, since a run of failures is the engine rather than the episodes -- while one bad file among good ones is still marked, because the same bytes will fail the same way next time. NeedsRecognising is deliberately not the negation of TranscriptExamined. A declined transcript means examined, no words, and exactly the episode recognition exists for. **First validation against ground truth the detector did not produce.** Up First "Democrats Lead In NPR Poll": the feed declares 788s, the file served 890s, so 102s of advertising was inserted -- and reading the recognised words found 100s of it, in two pods, every line unmistakable ad copy. The sibling episode found 21s of 131s: a whole pod at 54-130s was missed because its copy carries only one kind of marker. Reported rather than tuned around, on two episodes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-28Read the advertising out of an episode's wordsPeter Stone
A second ad detector, and it finds a class the first structurally cannot. The audio scan compares episodes mirrored within a minute of each other, so what it finds is advertising a CDN stitched into all of them -- validated against the declared-versus-served length gap. A show that inserts nothing has no gap, which by that measure means it carries no advertising, and the host-read sponsor block in the middle of it is invisible. Every published transcript the server can use belongs to exactly such a show, because having no inserted advertising is the condition for using one. The two detectors do not merely fail differently; they look at disjoint sets of episodes. **Density, not phrases.** One marker is a mention. Measured against the 38 transcripts harvested from the live library: a single "brought to you by" is a one-second tag inside play-by-play and skipping it would cut the game, and a guest describing how a sponsor came forward for his yacht event says "sponsor" four times while being the programme -- the same trap the toc=false chapter rule already documents. A real read stacks different *kinds* of marker inside a few seconds: who it is for, where to go, what the offer is, what the terms are. Two kinds and fifteen seconds, the same ad unit the length-gap rule uses. Every threshold comes from a measurement rather than a guess. The 25-second bridge is the 17.7-second gap between "presented by Delta" and "12% off" in the Seahawks Rewind read, with the product description in between. "Slash" needs a domain in front of it because the corpus contains "a children's movie slash book for adults". Across all 38 live transcripts it reports **one** candidate: that Delta block, 41:07 to 41:33, with no programme clipped at either end. That is low recall by design -- these are the shows with the least advertising, because they are the only ones whose transcripts can be used at all. The shows with real ad breaks need speech recognition, which is not built. **Nothing is published as a skip.** The candidates are stored with the words and the lines are dimmed on the transcript screen, so the reader can see what the server thinks is an advertisement and disagree with it. Dimmed rather than hidden, because a wrong call has to stay visible. Nothing in the playback path reads them -- same posture as the signature library's shadow run, and the same reason: a detector acting on its own unchecked first guesses makes skips into the middle of the programme, which is worse than no skips. Schema 18 adds the mark to the cue, defaulting to not: a line nobody judged is not an advertisement. Migrated and checked on a device. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-28The app reads an episode's words, and follows them while they playPeter Stone
The ⋮ menu gains Transcript, which opens the episode's lines with the one being spoken in bold and the list following the playhead. Tapping a line seeks to it, which is the thing anybody actually wants a transcript for -- finding the bit you half-heard. It is also why the server refuses a publisher's transcript for an ad-inserted copy. Tap a line in a transcript of the ad-free master and the audio starts minutes from where the words are. The words arrive with the audio rather than when the screen opens. The point of downloading is that the episode works with no network, and a transcript fetched on opening is blank on a plane or in a car park, which is where this app is used. Tens of kilobytes beside tens of megabytes. One row per cue, not a JSON column. Android reads every Room row through a CursorWindow that refuses a row over 2MB *on the way out* -- the insert succeeds and the next read throws -- which is exactly how the oversized pendingClipPcm crashed the app on every show's second fingerprinted episode. A three-hour episode is thousands of cues, so one column holding them all is that bug with a different name. Both tables cascade from the episode. Schema 17, migrated and checked on a device, including that deleting an episode takes its words. Three states again, kept apart: words to read, a recorded reason there are none -- the server looked at the publisher's transcript and would not use it for this copy -- and nobody having looked. The menu offers the entry for the first two and the screen says something different for each. TranscriptSyncing holds the highlighting, apart from the screen and tested, because the way this goes wrong is quiet: a highlight on the line before or after the one being spoken looks right at a glance and is useless to read along with. It holds the last line through a gap rather than flicking off during silence or music, and highlights nothing before the first line, since an episode opening on theme music has not said anything yet. Verified end to end against a real feed before committing, on a throwaway data directory: Wards Way Podcast mirrored, both episodes' transcripts taken, and the API served 748 cues whose last one ends 466ms before the measured file does. Transcripts live on their own view model rather than two more functions on ListenViewModel, which was already at the size the linter allows -- same call as splitting Screens.kt, and they have a different lifetime anyway. The coverage gate caught the replace transaction having no direct test, which was fair: its clear-then-insert ordering is the thing that would leave a reader looking at an empty list under a header counting lines. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-28safefetch: an allowlist, and no proxy to go round itPeter Stone
Two follow-up findings on the same file, both real. **A proxy would have made the whole check pointless.** http.DefaultTransport carries ProxyFromEnvironment, so with HTTP_PROXY set the connection goes to the proxy and the proxy connects to whatever the URL named -- the address being checked would be the proxy's, and the one that matters would never be looked at. The safe client now sets no proxy, and a test asserts it. **"Not RFC 1918" is not the same as "public".** The check is now an allowlist: an ordinary global unicast address that is not in an IANA special-purpose range. The ranges that were being let through include 100.64.0.0/10, which is carrier-grade NAT and routes to a real host on a real network, and 64:ff9b::/96 and 2002::/16, which are NAT64 and 6to4 and carry an IPv4 address inside them -- 127.0.0.1 included. None is private by Go's definition and none is somewhere a podcast is served from. Checked against the live library before committing: 130 real URLs -- all 44 feeds and two enclosures from every show -- resolve to addresses this permits, so nothing Peter subscribes to stops mirroring. That check is kept as a test which skips where the state file is not present, because the failure mode of being too strict is a feed that silently stops updating. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-28Every outbound fetch may only reach a public addressPeter Stone
A security review flagged the transcript download as an SSRF: the URL comes from a <podcast:transcript> tag, so a publisher chooses it. It was the third place with that shape, not the first -- the feed fetch takes a URL the phone sent, and the enclosure download takes one from inside the feed, and both had been doing it for longer. Fixing the flagged instance alone would have fixed nothing. The exposure is specific rather than theoretical. listend listens on 127.0.0.1:38090 behind Apache on a host running other services, so a feed naming a loopback, private or link-local address -- 169.254.169.254 among them -- would have the server fetch something only it can reach. In the transcript case the result would then be stored and handed to the phone as an episode's words, which turns an internal reachability problem into a readable one. The check runs when the connection is dialled, not when the URL is read, so it also covers a redirect into an internal address and a hostname that resolves to one. The address connected to is the address that was checked. internal/safefetch now has a structural test that walks this server's own source and refuses any production file that builds its own HTTP client or uses the shared default. It was watched failing against a deliberately added violation before being kept. Tests still inject a plain client through the seam each type already had, because a httptest server is on loopback by definition -- which is exactly what production is now refusing. Checked against the live subscription list before committing: all 44 feeds use https and none resolves to a private, loopback or link-local address, so nothing Peter subscribes to is affected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-28Take publishers' transcripts, where they describe the copy we mirroredPeter Stone
Eleven of the 44 subscribed feeds carry <podcast:transcript>, in SRT or WebVTT. Those are the publisher's exact words with the times they were said, which is free and better than anything recognition would produce. **They are only usable when the show does not insert advertising**, and that turns out to be exact rather than approximate. A published transcript describes the publisher's master. Measured against the live library on 2026-10-28: Darknet Diaries "Conti" declared 3557s served 3813s last cue 3544s Wards Way "A Family Bar" declared 1216s served 1217s last cue 1213s LOW "Afterword" declared 610s served 610s last cue 603s LOW "Part 5: Bane" declared 2822s served 2823s last cue 2807s The transcript tracks the *declared* length every time. So it aligns precisely when served and declared agree, and is minutes out of step precisely when they do not -- which is the case where a transcript would be worth most. Reading along with one of those would put every line in the wrong place, and a break placed from it would seek into the programme. transcript.Fits makes that call from the two lengths rather than from the words, because the lengths are independent of the transcript: the feed declares the ad-free programme and the file measures what was served, so their difference is this copy's advertising. It is the same figure and the same fifteen-second unit the ad scan already validates itself against. A refusal is recorded, not retried. It is a judgement about this copy, so re-fetching every poll would be work done for ever to reach the same answer -- and the refusal is the record of which episodes speech recognition will have to do. Refused, taken and never-looked-at are three states, kept apart here and in the API, because conflating the first and third is the readout mistake this project has already made twice. Run against the live library before committing: 342 mirrored episodes, 57 offered a timestamped transcript, 38 taken, 19 declined -- and every decline is a show with dynamic insertion (Heavyweight 10:03, Incels 11:54, Darknet Diaries 4:23). Transcripts live one file per episode beside the audio, not in state.json: that file is the only record of where mirrored audio is and is rewritten whole on every label and every poll, and Darknet Diaries' "Conti" alone is 1784 cues. They are pruned with the audio they describe. Both spellings of the Podcasting 2.0 namespace are read. Every feed here declares https:// today, but the specification was published under http:// and Go matches a namespace by exact string -- so the wrong one would mean episodes silently never getting words, with nothing failing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-28The library row carries the show's artwork, not a dotPeter Stone
The dot said only whether anything was waiting. The count beside the title says that and says how much, so the dot was the row's least informative element sitting in its most prominent position. The artwork spans both lines, which is what makes it read as belonging to the show rather than to the episode named underneath it, and it carries the caught-up dimming with the rest of the row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-28Library rows offer the newest waiting episode, and the car sorts like the phonePeter Stone
Three things Peter reported about the podcast list. **A slow-drip show sorted on episodes it was hiding.** The library's count and its sort key were computed from every unplayed, unqueued episode, released or not, so New Yorker Fiction ranked near the top on a brand-new episode drip had not handed out yet -- a show with nothing visible sitting above shows with something to hear. Both halves now apply the same released-set the show screen shows. The count was wrong in the same way and for the same reason, so it moves with it. **The row now names the one episode you would actually play**, under the show's name and in a quieter style, with the same Play, Queue and Download controls the episode list offers. The show stays the focus: this is a list of shows, and an episode offering different actions depending on which screen you found it on would be two features wearing one name. `observeNewestWaiting` sits beside `observeAll` because the two must agree on what "waiting" means -- a row that offered an episode the count excludes would be offering something the show claims not to have. **Android Auto lists shows in the library's order now.** It was sorted, but alphabetically, which is a different order from the phone's and so reads as no order at all. `getBrowsable` is gone rather than reordered: the callback reads `observeAll` directly, so there is one definition of what the library contains and what order it is in, and the car cannot drift from the phone again. Alphabetical was also wrong on its own merits -- the car hides played episodes, so a show you are caught up on browses as an empty list, and burying the ones with something to hear among them is exactly what a driver can do nothing about at a traffic light. Pinned by `MediaBrowseTest.theCarListsShowsNewestWaitingFirst`, which walks the real session protocol and failed on alphabetical order before the change, five new drip cases and five newest-waiting cases in `LibraryOrderTest` against a real database, and `LibraryRowTest`, which drives the row as a composable because a row offering the wrong episode's controls looks exactly like one offering the right episode's. `LibraryScreen.kt` split rather than relaxing detekt again, same call as `Screens.kt`: the row and its parts are now `SubscriptionRow.kt`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-27Plan: the labels are wired end to end, and when they are not appliedPeter Stone
Verified on the live server: a mark vouching for a real detected break reaches the signature that break produced and sets confirmed on it. The probe and the flag it set were removed afterwards; that corpus holds the listener's statements, not test data. Also records the property that cost two runs to notice -- a label is only actionable while its episode is still mirrored. They outlive the audio deliberately, so the server holds plenty naming episodes long rotated out, and those are simply not applied. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-27Use the labels: seed signatures from them, and take them backPeter Stone
The labels have been arriving and sitting there. Now the shadow run acts on all three kinds. A report is advertising pointed at where detection found nothing, so it becomes a signature in its own right -- that being the whole gap the library exists to close, since the scan accounts for about 38% of what the feeds imply is there. A mark saying a break is right adds no audio, because the break is harvested anyway. What it adds is that a person vouched for it. A mark saying a break is wrong does two things. It stops that audio being harvested, and it takes back any signature already held that matches it. That second half is the one that matters: a signature built from programme would cut the programme in every episode carrying it, until somebody noticed. So his "no" removes it outright rather than lowering a score. Being pointed at outranks partners and occasions both. Those are ways of inferring what a stretch of audio is; he was there. A confirmed signature is trusted at once and stays confirmed -- he does not un-hear it next week. A report whose end was never corrected is trimmed to its first fifteen seconds. An uncorrected end is wherever the 25-second jump landed rather than where the advertising stopped, so its tail may be programme -- and one ad unit is already far more audio than a landmark fingerprint needs to be distinctive. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-27Start testing the Compose screensPeter Stone
The dependencies for this were already in the build -- ui-test-junit4 on androidTest, ui-test-manifest on debug -- and had never been used. So the spike was one test, and it passed first run. Eighteen now. What they cover is the data-driven composables, chosen because a regression in them looks completely normal: a readout that quietly stops printing one of its three numbers, a control that stops reporting whether it will play or pause, a bar that stops naming what is playing while a screen shows something else. The episode transport: the ad button is offered only while something is playing, because with nothing playing there is no advertising to be in the middle of and an enabled button would record a claim about a position nobody is at. The primary control says what pressing it will do. The skip buttons move by the amount they advertise. The skip readout: all three numbers on one line, the status when there is nothing to cut -- "nothing found" and "not checked yet" being different answers is the mistake the old readout made -- and a legend that names only what is actually on the track. The now-playing bar: names the episode and the show, opens what is playing when tapped, and reports play against pause. Screens that need view models are still verified by eye. This is the half that did not need them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-27Write down a refused token instead of only remembering itPeter Stone
401 and 403 are permanent by decision -- a refused token cannot fix itself -- and the notice saying so was held in memory. So it vanished with the process and only came back once the next refresh had gone and been refused all over again, which for 61 feeds is hours of the app quietly downloading from publishers with nothing on screen to say why. It now lives in the preferences file and is there at launch. Saving different credentials clears it at once rather than waiting for a request to prove it, because that is the one act that answers the complaint. Saving the same ones again proves nothing and leaves the notice standing. Behind a small interface so SkipServer does not have to know whether it is writing to a preferences file or to nothing: every test wants it in memory, without a Context to hang a DataStore off. The stored one takes the reading and the writing as two narrow seams rather than a Preferences. Passing Preferences dragged a Context in and made it the one piece of the server client that could only be exercised on a device -- the coverage gate caught that, which is what it is for. What it actually decides, when *not* to write, is now plain Kotlin with five tests on it: clearing runs after every successful reply, so two hundred successes in a sweep must write nothing. Verified on a device: a refusal written by one Preferences instance is read back by another, and four cases around saving credentials behave as described. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-27Follow the player, and put the ad button on the episode screenPeter Stone
Peter: "combining the player and episode screens is weird. Now I end up with a different episode playing than shown because it doesn't follow what's playing. And there should be an ad skip button in the player." Both are consequences of the merge rather than arguments against it. The screen is the player now, so when the queue advanced the screen stayed on the episode that had finished -- and its transport then belonged to an episode nobody was listening to, so pressing play there would restart it over the one that was. It follows only when it was already showing the playing episode. Opening an older episode while something else plays is a deliberate act, and a screen that snatched itself away mid-read would be a worse bug than this one. The route argument could not answer it alone: the queue advances with no navigation at all. The ad button sends the same session command the notification button sends rather than reimplementing it. That gesture is four meanings deep -- press, press again, come back inside the jump, come back past it -- and two copies of it would drift. It carries the same circle-and-slash so the two read as one control in two places. It is on the episode screen for the same reason it is on the notification: detection accounts for roughly 38% of the advertising the feeds imply, so most of what gets heard was never found. Verified on the emulator with two twenty-second episodes queued: the screen was showing the first, playback reached its end, and the screen moved to the second on its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-25Plan: step 4 of the UI redesign is donePeter Stone
Records what moved and why, including the two controls that left Settings -- speed because the episode screen already had it, the sleep timer because it is an action rather than a setting. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-25Library and settings, with less in themPeter Stone
Step 4 of the UI redesign, and the last of it. The library is one list rather than a list plus a card. Up Next is a heading and a row, lining up with the rows under Shows instead of sitting in a bordered box above them. A show row is a dot, a title and a count: forty-three shows is a list to scan, not a shelf to browse, and the one thing worth seeing per row is whether there is anything in it. The count is new -- the query had already counted it to do the ordering and was collapsing it to a boolean on the way out, so the screen said "something waiting" where it could say "four". Settings is three groups ordered by how often they are touched. Skipping first, because it is what is actively being tuned. The build hash drops to the bottom under About, rather than sitting between the automation rules and the subscriptions at equal weight. Two things left Settings. Playback speed was already a tap on the episode screen, so the permanent row of chips was a second way to do one thing. The sleep timer moved to the episode overflow, where every other secondary playback action went in step 3 -- it is not a setting, it is something you do once, while listening, usually in the dark. The entry shows the time remaining, which is the only question anyone has about a timer they set in the dark. The reachability test earned its keep here: dropping the speed section orphaned PlayerViewModel.setSpeed, which it reported as a feature no screen could reach. It was genuinely superseded -- setSpeedFor persists the choice per show and applies it -- so it is deleted rather than given a new home. Verified on the emulator, which is the only way Compose gets verified here: the library renders dots, counts and a dimmed errored feed; the three settings groups read in order; and the sleep timer arms from the overflow and then reports "Sleep timer - 29:51". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-24Fix the shadow run: resolve audio paths, and survive a bad filePeter Stone
The first real shadow run died on its first batch. Two faults, and the second is the one worth keeping. store.Episode.Path is relative to the audio directory. Detection joins it before handing it to the decoder and this did not, so ffmpeg went looking in the working directory and found nothing. Then that single failure aborted the whole show's learning. Every other per-item loop in this server isolates one item's failure -- the refresh sweep, the poll loop, the detection pass all say so in their own comments -- and this one propagated instead. An unreadable episode is now counted and stepped over. Counted once per episode rather than once per failure: a file that cannot be read fails both the matching pass and the harvesting pass, and reporting it twice would overstate the damage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-24Plan: mark the library built and the shadow run gatheringPeter Stone
Four and five are done and six is deliberately not. Everything six needs exists; what is missing is weeks of real batches, and building it now would throw away the only reason the shadow run exists. Records what to watch for in the log, and the one decision the tests forced: a copy that arrives with partners is trusted at once, because holding it back would mean only ever proposing advertising detection had already found twice. A cue gets no shortcut, which is the independence intro and outro are waiting for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-24Shadow run: log what the signature library would cut, cut nothingPeter Stone
The library now learns from every detection pass and says what it would have done, without doing any of it. The log is the deliverable: a few weeks of real batches answers whether it finds advertising or accumulates junk, before one extra second is skipped from something somebody is listening to. It matches before it harvests, deliberately. Harvesting first would let a batch's own signatures match the batch they came from, and every run would report itself a triumph. A detection pass is one occasion however many of its episodes carried the audio -- they were mirrored within minutes and all ran whatever campaign was running then. What a break's partner count does buy is different: an advertisement is published only because the audio recurred across episodes and correlation proved it, so a copy that arrives with partners is already a confirmed repeat rather than a single unsupported sighting, and is worth matching at once. Furniture gets no such shortcut -- that is exactly the independence the withheld intro and outro offsets are waiting for, and a sting shared by all eight episodes turned out to be an advertisement once already. Proposals are marked novel when detection did not already publish a break covering them. Those are the only interesting ones: the library earning its place rather than agreeing with what is already known. They are logged individually, with their votes and how many occasions and partners back them, so they can be listened to and judged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-24Store signatures, and age the ones that stop matteringPeter Stone
The spike proved landmark hashing separates a real repeat from a coincidence; this is the library it unlocks. Rebuilt test-first rather than lifted from the throwaway harness, against a fixture whose partials shift several times a second -- a steady tone has only a handful of peaks repeated endlessly and collides with itself far more than any recording does, so it would have flattered the design. Two kinds, which behave nothing alike. A cue is a show's own furniture, belongs to one feed, and never expires: a break stinger is not a campaign, and a show that goes quiet for a year comes back using the same one. It goes when the subscription does. A copy is one advertisement, belongs to nobody, and is forgotten six months after it was last heard. Confidence is a count of separate occasions, and a whole batch of episodes mirrored in one minute is one occasion however many of them carried the audio. That is the same independence problem that made eight agreeing episodes prove nothing about an intro -- they all ran whatever campaign was running that minute. Nothing is matched against until it has been seen twice. Signatures merge by matching rather than by identity, because the same advertisement re-encoded by another CDN is not byte-identical and would otherwise pile up as a new signature every week. Stored as packed base64 rather than JSON arrays: three thousand hashes per signature is 30KB as numbers and a quarter of that packed, and this file gets parsed on every start. The index is deliberately not stored, and is rebuilt on load -- skip that and every signature silently stops matching after a restart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-24Send the listener's labels to the server and keep themPeter Stone
The phone has had two kinds of label and nowhere to put them: a mark, which is a judgement on a break detection found, and a report, which is the notification button pointing at advertising it missed. They catch opposite errors, and the signature library needs both. POST /api/labels now takes them and state.json keeps them. Episodes are named by feed and guid rather than by either side's own id -- the phone never sees the server's, and the server's belongs to a mirror that forgets an episode after eight. The labels sit beside the shows rather than on them for the same reason: the corpus has to outlive the audio it was given on. A report's end is sent as guessed or measured. It is only measured once the listener came back and trimmed it; otherwise it is wherever the jump landed, and a signature cut to that would carry programme. The phone re-sends everything each sweep and keeps no record of what arrived. The server replaces rather than accumulating, so a re-send is free, there is no sync state to get wrong, and a label lost to a crash comes back on the next refresh. Gated on audioFromServer, like every other server re-query: a label names a position in a particular file, and dynamic ad insertion means the publisher's copy is a different one. Accepting a label writes the state file immediately. Everything else is written at the end of a poll cycle, which for 61 feeds is hours, and a tap made once should not wait that long to become durable. Deployed and checked against the live server: a label is accepted, a re-send leaves one, an unauthenticated POST gets 401, and the label is on disk within seconds. The probe label used for that check was removed afterwards. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-24Spike: landmark hashing passes the gate for the signature libraryPeter Stone
The plan made this the gate: a linear scan of N stored signatures against each episode does not survive, so the library is only possible if an index can shortlist candidates cheaply. It can. Built as a throwaway harness against the live corpus. The episodes the plan named had rotated out of the server's 8-episode window, so the test set was rebuilt from current audio and came out stronger than the one it replaces -- 6 true repeats and 16 non-repeats, against 2 and 3. Six repeats scored 813-1564 votes; sixteen non-repeats scored 27-94. The narrowest separation is 8.6x, where the correlator it replaces manages 4.9x on the same audio. Nothing sits in the gap. Offsets land within 1.3s and every proposal is cheap to confirm by correlating against a short window at the claimed position, so the shape is: index proposes, correlator disposes. That is not a formality. In one episode the advertisement occurs twice and the index picked the weaker instance, which only a confirming pass would catch. Measured: 9.8ms to hash a signature when it is stored, 196ms to look one up against 23 episodes, ~8s per episode to index, 353MB for 23. Five hundred signatures cost 98 seconds where linear correlation costs about 63 minutes. The harness is not committed -- it read absolute server paths and had no tests, and its output was an answer rather than code to keep. The parameters it found are written into the plan so it can be rebuilt test-first, along with the one weakness worth knowing: the hashes collide heavily, and that density is what makes a lookup cost milliseconds rather than microseconds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-20Jump 25 seconds, the forward button's amount, not 45Peter Stone
The "this is an ad" press jumped 45 seconds, a number argued from ad-unit arithmetic rather than from the controls that already exist. Peter: "the jump should stay 25 seconds. Don't go inventing new timing." Skipping forward is already what you do to an advertisement, so the button goes the same distance and additionally says why. One number to know, and a second press goes the same distance again. The amount had three definitions already -- the player's increments, the episode screen's transport buttons, and the notification caption's hardcoded "Forward 25 seconds" -- and this nearly became a fourth with a different value. They now all read one, and the captions are derived from it so a caption cannot name a number its button does not do. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-20Add a "this is an ad" button to the notificationPeter Stone
The notification carried four buttons doing two jobs: Media3's default previous/next (which PodcastPlayer redefines as seek) plus our own back-15/forward-25 pair. Media button preferences with explicit slots let the custom pair claim the two seek slots, so the duplicates go and the freed space carries "this is an ad". No player command is removed to achieve it, which is the route that once silently killed the headphone skip button. One press means four things, told apart by where the playhead lands rather than by guessing: press claims advertising from here and jumps past it, pressing again extends the same claim, coming back inside the jump moves its end to where you landed, and coming back past the press withdraws it. Outside a 20-second window a seek is ordinary navigation and says nothing. The window is stored with the label so changing it later cannot reinterpret old data. Every backwards seek feeds the gesture, whatever caused it -- notification, headphone remote, in-app bar, dragged slider -- because you correct with whatever is to hand. This is the opposite evidence to the tap-a-break popup. That one can only correct breaks detection already found, and detection accounts for about 38% of the advertising the feeds imply. Verified on the emulator: the notification now shows exactly back-15, pause, forward-25 and the new button; the lock screen shows it too and pressing it there records a claim without unlocking; all four gesture arms behave as specified; and the media transport keys still seek by 25s and 15s. Android Auto still needs the car. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-20Publish how many episodes shared each break, and plan the restClaude
The server has computed and stored Partners since ad detection shipped -- state.json carries it on every break -- but breakRange never put it on the wire, so the phone's new tap-a-break popup could only ever say "the server did not say how many other episodes carried this audio". That count is the one piece of evidence a listener can weigh before judging a break, and the popup exists to show it. My brief for the popup asserted the server already sent it. It did not. The implementing agent checked rather than taking my word, which is why this is a two-line fix now instead of a wrong "why" line forever. Also lands the handover plan for the correction channel and the signature library, written before a context compaction, including the gesture design Peter specified for the notification button and the jump-size argument he correctly rejected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-20Let the listener validate or invalidate a detected break from a popupPeter Stone
Keeps partners on episode_midrolls (schema 15), stores verdicts in their own break_verdicts table so a server refresh cannot wipe them, and drops invalidated breaks from skipping on the next tick. Verdicts are local only. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-19Episode screen: show name in the top bar, chapters fetched lazilyPeter Stone
The top bar now names the show (from the already-loaded subscriptions list, "Episode" if unknown) and the body keeps the episode title, wrapping to two lines. Chapters are no longer fetched when the screen opens; the overflow offers the entry whenever a chapters URL exists and the dialog fetches (cached) on open, saying so if the file is empty. Playback's ad-chapter path (PlaybackAdvancer/PlaybackSources) is untouched. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-19Make the episode screen the player and delete the separate player screenPeter Stone
Opening an episode now puts you in its transport at the stored position; pressing play changes nothing about the layout. One primary control (Play/Resume/Pause) with back/forward, a speed chip that opens the four offered speeds (stored 1.5/1.75 still play and are named on the chip), and everything secondary behind a top-bar overflow: queue, download, played, watch, mark-older, chapters, and the publisher-copy action (behind a dialog carrying its warning). Description shows about two lines, the rest on tap. The now-playing bar, library bar, queue, share receiver and nav graph all open the playing episode's screen. PlayerViewModel stays the single owner of playback state; no playback decision moved into the UI. PlayerScreen.kt and WatchButton.kt are deleted. Coverage drop, if any, is from removing covered dead code. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-19Record that lowering the noise floor to 8s is wrongClaude
The 12s floor cut real detection on shows with verified ads -- LeVar Burton Reads fell from 19.0 to 10.5 minutes found against a 26.6 minute gap -- so 8s looked like the fix. Measured on an isolated copy of the live data it is not: Sleepy History at 8s finds 174 minutes of "advertising" in a show that has 48 seconds of it, worse than the 4s floor that produced Peter's original complaint. MinRunSamples and MergeGapMs interact and cannot be tuned separately. A low floor lets noise fragments survive and the 60s bridging then welds them into enormous spans. Had only LeVar been measured the floor would have been lowered. Also re-pins .coverage-baseline, which the hook left modified after d96c876 deleted covered readout code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-19Replace the skip readout rows with three numbers and tappable marksPeter Stone
Served / declared / cut on one line, a two-colour legend, and marks on the track that name themselves and seek when tapped. The per-break list and the permanent-None intro/outro rows are gone. Coverage: dropped 94.23 -> 94.15 because covered readout code was deleted. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-17Record that the silent publisher fallback is goneClaude
CLAUDE.md still described it as deliberate design with its original rationale, which would have had a future session reintroduce the exact behaviour Peter asked to remove. Also records the three cases that must keep downloading directly, why a rejected token is permanent rather than transient, and what audioFromServer is for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-17Treat a rejected skip-server token as permanent, not a reason to waitPeter Stone
HTTP 401/403 from the skip server now downloads the publisher's copy at once (audioFromServer=false), as if no server were configured. Unreachable, timeout, 404, 5xx, unparseable and pending still wait. The transport reports Body / Rejected / NoAnswer and makes no decisions. The rejection is remembered (from episode requests and pushSubscriptions) and shown in the skip-server settings until a different token is saved or a request succeeds. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-17Wait for the server's copy instead of silently taking the publisher'sPeter Stone
With a skip server configured, a podcast episode it cannot supply yet (pending, unreachable, bad answer, or audio that fails) now enters WAITING_FOR_SERVER and is retried on every refresh, instead of falling back to the publisher's differently-ad- inserted file. Taking the publisher's copy is an explicit action on the episode screen that sets audioFromServer=false. No server configured, YouTube/Facebook and unresolvable feeds still download directly. No schema change: downloadState is stored by name as TEXT. detekt: EpisodeDao and ListenViewModel function-count thresholds raised by one (rationale in detekt.yml). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-17Refresh skips with bounded concurrency, not one at a timeClaude
The sweep awaited each episode's server query sequentially, and it runs on every refresh including the library button. Several hundred downloaded episodes meant a tap that used to take seconds took minutes, and under WorkManager it risked being killed partway -- the same shape as the ten-minute ceiling that stopped FingerprintWorker ever confirming. Six at a time, through a semaphore inside the call's own coroutineScope so nothing escapes to a background dispatcher. Per-episode failure isolation is unchanged; CancellationException is rethrown rather than caught, the bug already fixed once in skipFurniture(). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-16Refresh skips on feed refresh, without stamping server offsets onto ↵Peter Stone
publisher audio A feed refresh never re-asked the skip server for already-downloaded episodes, so detection improvements only ever reached a show by deleting and redownloading it. Fixing that safely needs to know which episodes are safe to re-query: a show using dynamic ad insertion serves a different file on every request, so a server-measured offset only applies to the server's own copy, not whatever the publisher fallback left on disk. - EpisodeEntity.audioFromServer (schema 13->14, MIGRATION_13_14) records which source actually landed; EpisodeDownloader sets it, defaulting to false everywhere else so a requeue, retention reclaim or failed re-download clears a stale claim the same way they already clear localPath. - New SkipRefresher re-queries downloaded, server-sourced episodes via the existing ServerSkips.resolve/record pair, one episode's failure not blocking the rest. Wired into SubscriptionRepository.refreshAll via a new refreshSkips collaborator -- the choke point every refresh route passes through, including the library's refresh button. - SkipSummary's episode screen readout gets a distinct line for a download that can never be refreshed in place, instead of the misleading "Not checked yet". Migration verified on-device (gradle-verify connectedDebugAndroidTest, MigrationTest: 14/14). Full connected suite: 31/31. Unit suite: 682/682. detekt/lint clean; coverage 93.86% against a 93.85% baseline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-16Say "breaks" on the fast-play toggle, not "ads"Claude
The toggle chooses what happens to whatever is being cut; the switch above it chooses what that is. With both on it races through the show's own intro and credits too, so labelling it "ads" undersells it. Caught in review -- the labels are the only explanation this feature ever gives.
2026-10-16Centre the headphones on the launcher iconPeter Stone
The artwork spanned y 46..80 (centre 63) against the 108dp canvas's own centre of 54 -- 9dp too low, as measured. Shifted every y coordinate in ic_launcher_foreground.xml up by 9: the arc's M and A commands, and the M of each earcup path (the rest of each path is relative, so only the start point needed to move). Now spans y 37..71, centre 54, matching the horizontal centre and well inside the adaptive icon's 18..90 safe zone. ic_launcher.xml and ic_launcher_round.xml both reference this one drawable, so no other file needed a change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-16Trim offered playback speeds to 1x, 1.1x, 1.25x, 2xPeter Stone
1.1 and 1.25 cover the gap 1x leaves and 2x is the fastest speech stays easy to follow at; 1.5x and 1.75x are dropped. A show, episode or rule speed stored before this change keeps playing at its old value -- the speed pickers just don't highlight a chip for it, same as any value typed outside the list already worked. speedLabel() is made internal and covered by a test; Float.toString() already formats 1.1x correctly on the JVM, so no formatting fix was needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-16Add a toggle to play ad breaks at 3x instead of skipping themPeter Stone
skipShowFurniture still decides which breaks get acted on; the new fastPlayBreaks preference (default off, matching today's behaviour) decides what the action is once a break is already due to be acted on. PlayheadPolicy.decide is restructured into tiers (finish, break, chapter, fallback-speed) chained with ?: so Finish still beats both skipping and fast-play, and to stay under detekt's return-count limit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-16Reverse: a served-shorter episode is furniture, not advertisingPeter Stone
Measured against the owner's 334 mirrored episodes, the symmetric durationSlopMs rule from the previous round costs far more than it protects: 31% of episodes are served shorter than their declared length (feeds are sloppy by tens of seconds -- one show ran -47s, -33s, +88s, -95s across episodes), and every break in every one of them fell to the unjudgeable default and got labelled "Ad break" -- including closing credits at 37:41 of a 38:25 episode. The owner's furniture toggle did nothing on nearly a third of his library. You cannot insert advertising into a file and have it come out shorter, so a negative gap is evidence that nothing was inserted, not evidence the feed can't be trusted. Classify's insertion test is one-sided again: inserted := servedMs<=0 || declaredMs<=0 || (servedMs-declaredMs) > adUnitMs. durationSlopMs (2000) is replaced by adUnitMs (15000) -- advertising is sold in ~15s units, so an overrun under one unit is the feed being approximate, not an insertion. Both unjudgeable cases (servedMs<=0, declaredMs<=0) are unchanged; furnitureKindAt's position logic is unchanged. This reverses the FIX-1 decision in this same file that made the test symmetric, on the theory that a feed wrong in the short direction couldn't be trusted to judge the other direction either. Rewrote the two pre-existing tests that encoded that decision (TestAnEpisodeShorterThanDeclaredIsAlsoAllFurniture, folded as a duplicate, and the renamed TestAServedLengthShorterThanDeclaredByFarIsStillFurnitureByPosition) plus one comment on an unchanged assertion, and added coverage for the new threshold's band (a 10s overrun is now furniture, not ad). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-15Raise the ad-detector's noise floor from 4s to 12s, bridge pods at 60sPeter Stone
Owner reported a 38-minute episode with 37 breaks, 14 of them exactly 4.0 seconds and scattered roughly one per minute through the whole thing: "This is madness. Ads are usually multiples of 15 seconds, grouped together in a few spots." He was right. DefaultRunOptions's MinRunSamples: 1000 is exactly 4.0s at the 250Hz discovery rate, so anything that long was taken seriously -- that floor, not a real ad, is why 14 breaks landed on precisely 4.0s: correlation noise pinned at the floor. Measured across 333 mirrored episodes: episodes the feed proves carry advertising (served >=60s longer than declared) have a median of 3 breaks, max 7. All six badly-fragmented episodes in the corpus belong to one show (Sleepy History), which reuses long ambient music beds -- the detector was matching the programme's own texture against itself. Fix, validated against the declared-vs-served length gap (a measure the detector does not produce and so can't flatter itself with): - MinRunSamples 1000 -> 3000 (4s -> 12s). 12 rather than 15 so a real 15s ad whose correlation lapses at one end still survives; both floors were measured and 12 scored better. - AdConfig.MergeGapMs 10_000 -> 60_000, so fragments of one real ad pod separated by more than 10s bridge into the single break a listener experiences, rather than staying fragmented. Corpus-wide: 1091 -> 539 breaks, agreement with the length gap 31% -> 34%, unverifiable skipping on no-insertion shows 73min -> 64min. Half the breaks removed and accuracy up is the signal they were noise. Both constants' doc comments rewritten to state the new value and why, not just the mechanics -- the comments are the documentation here. TDD: added TestDefaultRunOptionsFloorIsTwelveSeconds (no pin existed for MinRunSamples) and updated TestDefaultAdConfigUsesTheValidatedValues's MergeGapMs pin; both confirmed red against the old constants before the change. Three existing ads_test.go fixtures built at or under 10-12s were lengthened to clear the new floor (not weakened). Added TestScatteredSubFloorFragmentsProduceNoBreaksButARealPodStillDoes, confirmed it fails against the old constants and passes against the new ones. gofmt, go vet, go test ./... all clean (server/ only -- no app/ changes). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-14Stop swallowing cancellation in skipFurniture()'s crash guardPeter Stone
Fix 2's runCatching caught Throwable, including CancellationException. Skipping quickly between episodes cancels PlaybackService's context-load coroutine mid-read; the swallowed cancellation let that launch carry on and overwrite the new episode's context with the previous one's midrolls and skip flag, so the playhead could be moved by the wrong episode's breaks. Narrowed to an explicit try/catch that rethrows CancellationException and catches IOException specifically (covers CorruptionException, a subclass) rather than a generic Exception, with the exception logged instead of silently discarded -- keeping the original crash-guard finding intact. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-14Fix whole-branch review findings in break classification and furniture togglePeter Stone
Six fixes surfaced only by a whole-branch view of the kind/furniture work: symmetric served-vs-declared comparison in Classify so a short-served episode (e.g. 30:00 declared, 29:30 served) can no longer read a real pre-roll as the show's own intro; a runCatching guard around the DataStore read backing skipFurniture() so a corrupted preferences file can no longer kill the media service at episode start; a proper isAdvertising predicate on SkipSummary so the episode screen's ad/furniture colour split no longer depends on comparing against a renamable display string; a stale comment in PlayheadPolicy describing the pre-toggle behaviour; the already-unreachable servedMs<=0 guard in furnitureKindAt now fails safe to KindAd instead of KindCue; and an ORDER BY on getRanges so kind-filtered lookups are deterministic. Throughout: anything unknown, empty or unjudgeable behaves as advertising, never as furniture. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-14Put the furniture toggle on screenPeter Stone
Settings now has a switch for skipping a show's own intro, break cue and credits, following the existing Compose state-holder pattern (rememberFurnitureSkipSettings) rather than inventing a view model -- there isn't one in this app. Bundled with SkipServerSettings into a single SkipSettings parameter to stay under detekt's parameter-count limit, since both live in the same settings section. Advertising has no toggle and stays always-skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-14Name each break instead of calling all of it advertisingPeter Stone
111 episodes reported 'Ads inserted' about a show's own opening theme and closing credits.
2026-10-14Skip advertising always and the show's own audio by preferencePeter Stone
An unrecognised kind counts as advertising, so a kind added by a newer server does not quietly stop being skipped by an older app.
2026-10-14Add the setting for skipping a show's own audioPeter Stone
On by default, because skipping every detected break is what the app already does.
2026-10-14Keep the kind the server sentPeter Stone
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.