summaryrefslogtreecommitdiff
AgeCommit message (Collapse)Author
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.
2026-10-14Give a stored break its kindPeter Stone
Existing rows become advertising, which is what they behaved as: every row in this table was skipped unconditionally before kinds existed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-14Publish what kind each break isPeter Stone
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.
2026-10-14Fix break classification for zero-served and short episodesPeter Stone
Finding 1: Treat zero or invalid servedMs as unjudgeable, falling back to advertising classification to match the constraint that anything unjudgeable must be conservative (ffprobe duration probe returning 0 is plausible upstream defect). Finding 2: Use proportional edge windows for short episodes (< 240s) to avoid misclassifying interior and closing breaks as intro when opening and closing windows overlap. For a short episode with breaks at 0s, 50s, 90s in a 100s episode, classify them correctly as intro, cue, outro instead of all intro.
2026-10-14Decide whether a break is advertising or the show's own audioPeter Stone
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.
2026-10-12Plan the break-kinds slice and the furniture toggleClaude
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
2026-10-08Play episodes whose enclosure is still cleartext httpClaude
Reported from the phone: Show 62 of Hardcore History failed with ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED. Android has refused cleartext since API 28 and this app declares no exemption, so any enclosure published over http fails the moment it is streamed rather than played from a downloaded file. Dan Carlin's older episodes are published that way while the recent ones are not, which is why it went unnoticed; the manifest has not changed since long before the skip server, so this is not a regression from that work. Upgrading the scheme rather than permitting cleartext: that exact URL answers 200 over TLS and a range request returns 206 audio/mpeg, podcast CDNs serve the same path either way, and an exemption would weaken every other request the app makes for the sake of a few old enclosures. A host with genuinely no TLS is no worse off, since cleartext was refused already. Two paths, because they do not share a stack. Everything that goes through OkHttp -- downloads, feeds, chapters, artwork -- is covered by an interceptor on the one client the graph builds, which is the right home for a property of the app's networking rather than of any one feature. ExoPlayer has its own data source, so playback converts the URI as it builds the media item. Putting the conversion at the downloader's call site instead broke four of its MockWebServer tests, which was the tests making a fair point: a global network policy does not belong at one call site. Components under test build their own client and are untouched by the interceptor. Also stops the server starving on-demand episode requests. The poll loop and the request queue share a goroutine, so an episode the phone asked for waited out the whole cycle -- hours across 42 shows, which is why the tap that produced this report registered the episode and never fetched it. Now drained before each feed, bounding the wait at one feed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-08Record the approach-2 decision and the furniture toggleClaude
Approach 2 chosen, built in the phase order already proposed -- it is a superset of approach 1, so the ad-detection work still lands first and the library last. Advertising is always skipped; the show own intro, cue and credits obey a single preference defaulting to on, because that is what happens today. The toggle settles a bootstrapping question the design had left open. A furniture clip cannot be promoted until a second fetch occasion has passed, so until then there is nothing to label with. The zero-gap classification fills that gap: served length equal to declared means nothing was inserted, so every break is furniture and position decides which kind; otherwise a break is advertising unless a promoted clip says otherwise. Day one is correct on the 138 zero-gap episodes and identical to current behaviour everywhere else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-08Design: separate a show's own audio from the inserted advertisingClaude
Three defects turn out to be one missing distinction. Ad detection finds a median 67% of the advertising the length gap implies, because it can only see what two simultaneously-mirrored episodes share. It reports a show's own intro, break cue and credits as advertisements on 111 of the 138 episodes that had nothing inserted into them. And intro/outro offsets stay withheld for want of independent evidence. Peter's observation about theme music around ad breaks is the clue, and probing it showed both what it gives and what it does not: the cue is real, already detected, and marks a break's entry but not its extent -- scanning to r=0.6 finds one isolated hit per slot in ad-laden and ad-free copies alike, so there is no closing cue to bracket against. Writing this up changed the recommendation. The furniture library is only needed for the intro/outro problem; the mislabelling falls out exactly and for free from served length versus declared, and cues are identifiable from their signature within a single batch. So the proposal is now the cheap approach first, with the library kept for later and only if the withheld offsets are wanted. Nothing here is built. Review document, not an implementation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-08Bridge ad pods fragmented by brief lapses in agreementClaude
A pod of several advertisements is found as several matches, because agreement lapses between them. Published unbridged, 45% of the gaps between consecutive breaks came in under ten seconds and 152 of 262 multi-break episodes contained one -- which plays as a skip, a one-to- nine-second burst of advertisement, then another skip. Bridging also moves agreement with the declared length gap from 69% to 73% median, because what was bridged was advertising too. MaxBreakMs now applies to the matches rather than to what bridging makes of them. Applied after bridging it refused five episodes that bridging had pushed over the four-minute cap, Radiolab's Grass Apes among them -- a 3:55 pre-roll and an 0:18 tag two seconds apart fuse to 4:15, and the episode the whole chain was device-verified on would have published nothing. One enormous match is evidence of shared programme; a chain of ordinary matches a beat apart is evidence of an ad pod. MaxSkipFraction still guards the episode as a whole. Also pins the ad scan's production values, which were measured rather than picked, and records what probing Peter's theme-music observation turned up: the cue is real and already detected, it marks a break's entry but not its extent, a second fetch is not an ad-free reference, and 138 of 322 mirrored episodes contain no inserted advertising at all while the scan reports breaks on 111 of them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-07Examine each batch once instead of every pollClaude
The first full pass over all 42 feeds finished, then the server kept re-deriving the same answers: Radiolab reported "53:29 of 59:39" four times in one day. pollOne called detect unconditionally and the mirror skips files it already has, so nothing had changed between passes. At ~6.5 minutes a show that is 4.5 hours a cycle against a two-hour poll interval -- the machine never finished a cycle and never idled, burning 13h42m of CPU in 11h of wall clock, roughly 60% of it on the intro search whose results are deliberately not published. Show.DetectedBatch records the episode ids a completed pass examined. The batch is the unit rather than the episode because every offset and every break comes from comparing an episode against the others fetched alongside it, so one arrival or one pruning legitimately changes all of them. Order-independent, since feeds are not reliably sorted. Recorded only on completion, so a cancelled or failed pass is retried. Episode.DetectedAtUnix could not serve as the gate: it is only set for episodes appearing in result.Offsets, so an episode nothing was found in never got a timestamp and the gate would never close. Also records what the first full corpus showed, including a disproven hypothesis worth not retrying -- filtering breaks by partner count or run length looks obviously right and makes agreement with the declared length gap substantially worse -- and a correction: Sleepy History's 117 minutes of "advertising" was a show-level sum sitting almost entirely inside the two episodes its own guard already refused. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-06Follow the data: detect the advertising, not the introPeter Stone
The property that broke intro detection is the property that makes this work. Eight episodes mirrored within one minute are maximally dependent, because the ad server inserts whatever campaign is running into all of them identically. For intro detection that destroys the independence the confirmation threshold needs -- which is how it came to confirm an advertisement on 8 of 8 episodes. For ad detection it is the signal. And unlike intro detection it is checkable against a number the detector does not produce: the feed declares the ad-free length, the file served is longer, and the difference is the advertising. **Measured on the live deployment, Radiolab's eight mirrored episodes: the scan accounted for 53:29 of the 59:39 the length gap implies, with every individual episode between 71% and 105%.** That is the first time detection in this project has been checked against external ground truth and agreed with it. The old sliding search could never have done this -- one full-length correlation per window position, 199 seconds for a 240-second clip. BestLags computes the cross-correlation over every lag in a single FFT pair, which says where two recordings align; MatchedRuns then walks the extent of the agreement directly, window by window, with no transform. Sized against measurements rather than guesses: 2^22 is 1.69s, so a 70-minute episode decimated to 250Hz is about five seconds a pair. Silence is explicitly not agreement, or separate advertisements weld together across the pauses between them. Breaks publish as ranges into episode_midrolls, which MidrollSkip and PlayheadPolicy already read -- no playback changes and no migration. Intro and outro offsets are still computed and logged but no longer published, and the ad-derived ones already in the server's state were cleared. Two guards publish nothing rather than something wrong: an episode whose matches cover over 35% of it, and any single match over four minutes. A re-aired episode or a two-parter would otherwise be eaten. Both record the refusal, because "found nothing" and "refused what it found" are different states and conflating them is the readout mistake over again. Also three data-loss bugs, found by reading the journal after a restart rather than by any test. The subscription list -- the one thing the server cannot rediscover -- was persisted only when a whole poll cycle finished, which for 61 feeds is hours. A cycle re-applied its stale snapshot at the end, which would undo a push that arrived during it and then delete the audio of every show the snapshot omitted. And detection ignored cancellation, so systemd's 90-second stop SIGKILLed the process before the save-on-exit could run. All three fixed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-05Move skip detection to a server, and find that it detects adsPeter Stone
The server is Peter's design and it works: listend polls the phone's feed list, mirrors the newest 8 episodes per show, detects across all of them at once, and serves the offsets *and the audio they belong to*. Deployed on tungsten behind Apache at https://listen.terst.org. FingerprintWorker and MidrollWorker are deleted -- the battery drain is gone because the work is. Serving the audio is load-bearing. Dynamic ad insertion turned out to be on every episode: Grass Apes declares 22:05 and the server was served 28:18. An offset measured on one copy is minutes wrong for another, so the phone has to play the bytes the detector examined. The offsets land in the same columns the on-device detector wrote, so playback needed no changes and there is no migration. **The headline finding is a failure, and it is proved rather than suspected.** Detection reported the intro confirmed on 8 of 8 Radiolab episodes and passed its self-check. It had locked onto an advertisement. The chosen segment was searched for in an ad-free copy of the same episode fetched hours earlier: r = 0.0621. It is in the ad break, not the programme. The confirmation threshold assumes three *independently fetched* episodes. Mirroring 8 within one minute from the same ad server makes them maximally dependent -- whatever campaign is running is in all of them, identical -- and SelfCheck cannot catch it, because the segments genuinely are the same audio. Moving to a server fixed the cost problem and broke the independence the safety mechanism rests on. The published offsets are head-window matches, so they happen to skip pre-roll advertising, which is useful by accident and will move whenever the campaign rotates. It is not intro detection and is not described as such anywhere. The fix, not made: record when each episode was fetched and require the agreeing episodes to span more than one fetch occasion. Reasoning, and an argument for detecting the ads directly instead, in docs/detection-findings.md. Also recorded there: two tooling traps that each cost real time. `android-ui tap` can land on the soft keyboard, because the dump reports layout coordinates the IME does not move -- a Save button and a text field both appeared tapped and neither fired. And the server's token is alphanumeric now because `adb shell input text` ate the leading "-" of a base64url one and produced a silent 401 that looked like broken wiring. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-05Find why Tier 3 never confirmed, and port detection to GoPeter Stone
Peter's report: the phone runs hot, uses a lot of battery, playback takes a moment to start, and intelligent skipping has never done anything. **The hot phone and the dead detection were one bug.** The candidate search correlated 117 window positions against the whole of the other clip, re-transforming that unchanged clip every time -- 199 seconds per episode end, measured on a desktop, so minutes more on a phone. FingerprintWorker is a plain WorkManager worker, Android stops one at ten minutes, and nothing was persisted until a comparison returned. So it was killed mid-flight, saved nothing, and was re-enqueued to start over, forever. IntroOutroDetectorScaleTest is the tripwire whose absence allowed this: the unit tests ran on 300-sample clips while production ran on 1,920,000. Fixed coarse-to-fine, plus AudioCorrelation.prepareTarget so the target clip is transformed once per search rather than 117 times. 10.6x, and a test asserts the chosen reference window is byte-identical to what a full-resolution search picks. **Midroll detection could never have run at all.** decodeFull buffered a whole episode at its native rate -- 434MB for a 13-minute episode, no largeHeap -- to produce 6MB of 8kHz mono. It threw OutOfMemoryError into a swallowing runCatching and the episode was then marked checked forever. MonoResampler now streams the decode so the peak allocation is the output, and maxDecodableMs skips a longer episode before spending anything and without marking it checked. **Detection is moving to a server**, Peter's call, in Go to match podkit and doot. server/ holds the ported core, verified three ways because this is the second port of this maths and the first shipped a normalization bug: numpy's literals extracted mechanically from AudioCorrelationTest, the FFT against a direct DFT, and the whole correlation path against a brute-force Pearson. On four real Radiolab episodes it confirms the intro on 4 of 4, at 48.0s, 8.65s, 8.65s and 65.15s -- the varying pre-roll, observed in working code at last. skips.SelfCheck verifies located segments against *each other* rather than against the reference, which is a different claim: correlation is not transitive, so two segments can each clear 0.6 against a reference and be unrelated. It rejected the outro on its first real run (r=0.746), withholding offsets that would have ended three episodes early. Offsets are published only when they verify. Also: the episode screen gets a working scrubber with ad-break marks and a declared-versus-measured length readout, queue rows open the episode instead of doing nothing, PrepareTrace logs playback-start timing against episode length for the still-open pause question, and -PphoneOnly drops the emulator's CPython from a sideload build (114MB -> 99MB). Full history and numbers in docs/detection-findings.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-29Protect a show's Tier 3 pending episode from retention reclaimPeter Stone
Peter's call (option 1 of the three in docs/detection-findings.md): exempt an episode retention would otherwise reclaim while it is a show's pending comparison basis, rather than extending the grace period or accepting the gap. RetentionSweeper gains a pendingEpisodeIds seam (suspend () -> Set<String>, defaulting to empty) and filters its reclaim candidates against it before the grace-period policy ever sees them -- every existing caller and test was unaffected by the default. RetentionWorker wires the real thing: the union of two new, narrow DAO queries -- ShowFingerprintDao.getAllPendingEpisodeIds() (intro/outro) and MidrollDao.getAllMidrollPendingEpisodeIds() (midroll, reading SubscriptionEntity.midrollPendingEpisodeId). Applied to both fingerprinting mechanisms, not just the one that got diagnosed: MidrollUpdater's discovery has the identical rolling-pending- episode shape and would race retention the same way for a fast show, just silently, with no frozen row to reveal it. Not yet re-verified on a device -- the next live test is two Up First downloads close enough together to actually compare, confirming pendingEpisodeId advances to a real second episode instead of restarting. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sj3CoWEedM56bebByKgNxA
2026-09-29Find the actual root cause: retention races the pending-episode filePeter Stone
Triggered a fresh Up First download on the phone and watched androidx.work.workdb and show_fingerprints immediately after, live. A new FingerprintWorker job appeared and succeeded within seconds, and show_fingerprints updated for the first time since 2026-09-27 -- but instead of advancing toward a second comparison, it just replaced the stale pending episode with the new download. confirmedCount stayed 0. That's checkAgainstPending's own designed fallback when pendingClipOf can't re-decode the stored pending episode because its file is gone. And it is gone: RetentionPolicy's grace period is one day, and Up First is a fast, daily, ~15-30 minute news show that gets played through quickly. By the time a second episode is downloaded and processed, retention has very often already reclaimed the first one's file -- so the detector never gets two episodes' audio to compare at once, and keeps discarding its only candidate and restarting from whatever the newest download is. Cruising the Waves reaching a real Tentative candidate the same day is consistent with this: a show whose downloads happen to land further apart, or whose previous pending episode hadn't been played+reclaimed yet, doesn't hit the race. This was never a signal-processing problem -- DEFAULT_REFERENCE_SECONDS, MIN_SCORE and CONFIRM_THRESHOLD were the wrong place to look the whole time. Wrote up three options (exempt a pending episode from reclaim, extend the grace period for a show still building its first candidate, or accept the gap for fast shows) as a decision for Peter, not something to just change. Separately unresolved and now secondary: Up First's actual second download (2026-09-28) left zero trace in show_fingerprints, which the live test's fallback path would have if it had run at all -- most likely a deferred or dropped one-shot WorkManager job, not reproduced live this session. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sj3CoWEedM56bebByKgNxA
2026-09-29Find the real Tier 3 blocker: Up First's second episode never got comparedPeter Stone
The phone reconnected mid-session, so this moved from desktop simulation to live device state -- pulled listen.db and WorkManager's own execution history straight off it (read-only, via run-as + adb exec-out cat, nothing modified). The pipeline is not broken: show_fingerprints for Cruising the Waves Podcast shows a real Tentative INTRO candidate (confirmedCount=2, a populated referenceClipPcm) from an ordinary download today. Something does reach Tentative on this exact build. Up First specifically is stuck, and the evidence is more precise than "nothing found yet" suggested. Its show_fingerprints row for both INTRO and OUTRO hasn't updated since 2026-09-27T02:19 -- the moment its very first episode was ever processed. A second real episode was downloaded, played, and swept by retention the next day, and its midrollCheckedAtMs is set, proving MidrollWorker's decodeFull read that exact file successfully that day. But FingerprintUpdater.processEnd calls fingerprints.upsert() on every outcome, confirmed, tentative, or discarded -- so if any comparison involving that second episode had run at all, the row's updatedAtMs would have moved. It didn't. The second episode's FingerprintWorker run either was never enqueued, or decodeHead/decodeTail both returned null for it, and the latter looks unlikely given decodeFull succeeded on the identical file the same day. Read EpisodeDownloader.downloadOverHttp end to end looking for a download-vs-playable race; there isn't one -- it's a blocking download to .part then an atomic rename before onDownloaded fires. WorkManager prunes completed WorkSpec rows after about a week, so the actual historical FingerprintWorker run (if any) for that episode is already gone. Narrows "What to check on the phone" to one live test: trigger a fresh Up First download and watch androidx.work.workdb and show_fingerprints immediately after, the same way this session just did. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sj3CoWEedM56bebByKgNxA