summaryrefslogtreecommitdiff
AgeCommit message (Collapse)Author
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
2026-09-29Disprove the leading Tier 3 detection hypothesis on real Up First audioPeter Stone
The brief's leading hypothesis -- an 8-second reference window too wide for a ~6-second sting -- does not survive contact with real data. Simulating IntroOutroDetector.findCandidate exactly (the same normalized_xcorr/best_match math AudioCorrelation.kt uses, unmodified) against the four Up First episodes already cached from the original prototype: every one of six pairs confirms cleanly at both ends, at the app's real DEFAULT_REFERENCE_SECONDS=8 and MIN_SCORE=0.6 -- intro r 0.79-0.998, outro r 0.994-0.999. Also ruled out: the naive linear-interpolation resampler MediaCodecClipSource actually uses. Reproduced it exactly against a fresh re-fetch's native 44.1kHz audio and compared to a proper ffmpeg decode -- 0.9991 correlation, and the production simulation confirms identically either way. Two findings beyond the original brief: - Pre-roll length varies a lot (44s-94s across four episodes) but the global search already handles that; it isn't the blocker the first draft worried it might be. - The same episode URL, fetched fresh today against a copy from 2026-09-26, is byte-identical for the first ~50 seconds and then diverges completely -- sharper evidence for "dynamic ad insertion is the environment" than the Radiolab duration mismatch alone, though it lands after the useful intro signal so doesn't threaten detection specifically. Experiment 6 (re-check the Darknet Diaries trap) didn't need a new fetch -- the midroll prototype session's already-cached data answers it: the shared ~51s segment still matches at r=1.000 across a nine-year gap, which a truly rotating per-request ad could not do by chance. Looks like a real intro, not the trap the threshold exists to catch. Net effect: the algorithm and its tuned constants are no longer plausible suspects on real audio. What's left points at the specific on-device run rather than the approach -- see "What to check on the phone" for the three concrete things to look at that this sandbox has no way to check itself. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sj3CoWEedM56bebByKgNxA
2026-09-29Write the desktop session's brief for Tier 3 detectionPeter Stone
The fingerprinting runs on a device now and has still confirmed nothing for a real show. That is an open question, not a known bug, and answering it needs the prototype rather than the app -- only the prototype can show what a parameter change costs in false positives, which is the number that matters here. docs/detection-findings.md collects what this round established: that the pipeline works, that the confirmation threshold earns its place, and the finding that reframes the problem -- dynamic ad insertion means the same Grass Apes came back 29:34 on one fetch and 27:39 on another, so ad content and the offset of everything behind it differ per request. It lists the experiments in order, including using insertedAdMs as ground truth for the midroll scan, and what not to touch. Linked from the known-gaps list, which is where a future session looks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-29Start shared timestamps playing, and stop the feed clobbering real lengthsPeter Stone
**The hand-off.** Sharing a timestamped YouTube link now opens Listen on the player, already playing at that point. Two things were missing. The timestamp was only honoured on first insert -- the insert ignores a clashing guid, so every share after the first silently did nothing and playback resumed wherever Listen had last left it, which is exactly the going-back- and-forth case. And nothing started playing; the video landed in the library to be found. Re-sharing now moves the position and unplays the episode, because sharing a moment in something you finished is how you go back to it. A share with no timestamp still just adds it quietly -- interrupting whatever is playing would be rude. **A regression in yesterday's duration fix, found by following Peter's own observation.** He noted the 22:05-vs-29:34 gap was probably content versus ads. It is -- and that has two consequences I had missed. The first is that `EpisodeMatcher.mergedWith` rewrites `durationMs` from the feed on every refresh, correctly, because it is feed metadata. Writing the measured length into that column meant the correction survived until the next refresh and then silently reverted. So there are two columns now: `durationMs` stays the feed's claim, `actualDurationMs` is what the player measured, and `playbackDurationMs` prefers the latter. The second is that the wrong number was never only cosmetic. It sizes the decode buffer a full-episode midroll scan allocates -- truncating the scan by minutes on precisely the shows that have midrolls -- places the outro offset, and drives the finished check, which would mark a 29-minute episode played at 21 and advance the queue. Those all read `playbackDurationMs` now. And the third thing, which is Peter's idea and free: the difference between the two numbers is the advertising in the episode, with no audio analysis at all. `insertedAdMs`, shown on the episode screen, and a check on the midroll detector -- whatever it eventually finds should add up to about that much. Measuring it turned up something worth knowing: the same Grass Apes came back 7:29 longer than declared on one fetch and 5:34 on another. Insertion happens per request, so this is a property of a file, not of an episode. Verified on the emulator. Hand-off: cold start into the player at 12:39 from a stored 12:30, and the warm onNewIntent path at 25:14 from 25:00. The YouTube resolver itself cannot be exercised here -- this host gets a 302 bot page for every watch URL, as CLAUDE.md already records -- so the new code was driven through the intent it receives, with the resolver's own behaviour unchanged. Durations: list showed 22m on a fresh subscribe, 27m after playing, with 1325000 and 1659454 stored separately, and the episode screen reading "Ads inserted ~5:34". 550 unit tests, 12 migration tests on device, detekt and lint clean. Coverage 92.96% -> 93.02%. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-28Correct durations from the file, and make the notification open the appPeter Stone
Two from Peter, one of which turns out to matter more than it looks. **The list said 22m, the player said 29:34.** The feed is lying, not the parser: Radiolab publishes Grass Apes as `00:22:05` and the file ExoPlayer opens is 29:34. Checked against the live feed rather than guessed. The seven and a half minutes between them are dynamically inserted advertising -- the feed declares the ad-free content length and the CDN stitches in the rest -- so the error is large, systematic, and worst on exactly the shows the midroll detector exists for. The player is the first thing in the app that sees the truth, so PositionWriter now writes it back, through a new narrow PlaybackStateDao. Once per episode rather than once per tick: this runs on the position timer for the length of an episode, and a lookup on every tick is precisely the per-episode work that does not scale. A two-second disagreement is left alone, being a container arguing with an encoder rather than a lie, and an unprepared player reporting nothing never wipes what the feed said. PlaybackStateDao is a split by meaning, not by count -- it owns the two things playback learns and writes back, the only writes in the app whose loss is unrecoverable. It also keeps EpisodeDao under detekt's interface limit, which the new query had pushed it over; the config's own comment argues against splitting a DAO to satisfy a count, so this splits it for a reason that would stand on its own. **Tapping the notification opens the app**, via setSessionActivity. It had none, so the transport buttons worked and the body did nothing, which reads as breakage rather than omission. Verified on the emulator against the real feed: Grass Apes stored as 1325000ms on subscribe, 1770553ms after twenty-five seconds of playback. The notification now carries `contentIntent=PendingIntent{... startActivity}` in dumpsys -- I confirmed the intent is attached and targets this app's activity, but did not simulate the physical tap. That same dump corroborates what I told Peter about the notification being Auto's doing and not ours: importance=2, sound=null, vibrate=null, category=transport. ExoPlayhead joins the coverage exclusions. It is four one-line getters over ExoPlayer that happen to live in PlaybackService.kt, so it read as covered by that entry while actually being measured -- it is the seam that makes PositionWriter testable, not logic. 540 unit tests, detekt and lint clean. Coverage 92.73% -> 92.96%. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-28Keep bedtime shows out of the car, and midroll clips out of the databasePeter Stone
Peter, on the sixty-second cap from the last commit: "won't be enough for tangle or the daily show at least. Both have theme music around ads at the end at least." He is right, and the cap was the wrong trade -- trimming a clip to fit a database row truncates the very thing being identified, and the music wrapping a break is part of what makes it recognisable. So the clips leave the database. MidrollClipStore writes them as files beside the audio library and the row holds a path, which removes the length limit rather than negotiating with it: three minutes is 2.88MB, and a CursorWindow hands back nothing over 2MB. Migration 10->11 drops existing candidates instead of converting them -- a migration cannot write files, and every stored candidate was written under the sixty-second cap, so it is the wrong length anyway. They are a cache; detection starts again. The intro/outro fingerprints are untouched, being eight seconds and correct. StoredClip.fits now guards the one decoded clip still kept in a row, the intro/outro reference, at the point it is written. Unreachable at the current eight seconds, and there precisely because this table already shipped a clip that was not. Bedtime shows are gone from Android Auto's browse list too, as asked. Driving is the opposite of falling asleep and a car list is scrolled blind at a traffic light -- the same argument that already hides played episodes there. Voice search still reaches them: asking for a show by name is unambiguous about wanting it. Found while doing it: unsubscribing never deleted anything from disk. The dialog has always promised "episodes, downloads and playback positions", and the rows did cascade -- taking with them the only record of where the downloaded audio was, so the files stayed forever with nothing pointing at them. Midroll clips would have joined them. Unsubscriber deletes the files first and the row last, because the other order is exactly how the leak happened, and it drops the row even if a file will not delete: a show stuck in the library is the worse failure of the two. Verified: 533 unit tests, detekt and lint clean, 11 migration tests on the emulator. Coverage 91.84% -> 92.73%. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-28Bedtime shows, dimmed caught-up shows, a show menu, and skip visibilityPeter Stone
Four things Peter asked for, plus a crash found while verifying them. - Bedtime shows. A per-show flag (schema 8->9) that takes a show out of the library entirely; the moon icon in the top bar switches to listing only those. Not a category system -- the one grouping worth building is the one with behaviour attached, which is that entering bedtime mode also arms the sleep timer. BedtimePolicy owns the two cases that make that safe: it will not overwrite a timer set by hand, and leaving only cancels a timer bedtime itself armed. The mode is deliberately not persisted; a restart is morning. - Shows with nothing waiting are dimmed. Same condition the library sort already used -- nothing unplayed and unqueued -- now returned alongside it as SubscriptionListing.hasWaiting. That comes from a COUNT rather than the sort key on purpose: an episode with no publish date ranks at 0, which is right for ordering and would have greyed out a show with one sitting unheard. LibraryOrderTest pins that case. The lastError line keeps its error colour through the dimming, since a failing feed looks exactly like a feed with nothing new. - Per-show controls moved into an overflow menu and a settings sheet, and the tutorial prose is gone. Reaching the episodes used to mean scrolling past drip cadence and two paragraphs; Peter wrote the requirements and does not need them explained back. Unsubscribe went into the same menu, off the top bar where it sat one tap from Refresh. - Skip detection is visible at last, per show and per episode. SkipSummary is pure and its wording is tested, because the distinction that matters is confirmed-and-acting versus still-gathering, and "not checked yet" is a third state again -- midroll scanning runs nightly, so a fresh download legitimately has nothing to say yet. And the crash. Opening the new readout on a device threw SQLiteBlobTooBigException, which turned out not to be the readout's fault: show_fingerprints.pendingClipPcm held a whole decoded head/tail window -- 240s at 8kHz is 3.84MB as 16-bit PCM -- and Android reads every Room row through a CursorWindow that refuses anything over 2MB *on the way out*. The insert succeeded and the next read threw. Intro/outro detection therefore died the moment a show's second episode was fingerprinted and could never have worked as shipped; nothing off-device could have caught it. Migration 9->10 drops the column and keeps only pendingEpisodeId, re-decoding on demand -- exactly what midrollPendingEpisodeId already did for the same reason. StoredClip now owns the limit, MidrollUpdater caps a candidate segment at 60s before persisting it (a long sponsor block would hit the same wall) and refuses anything that still would not fit, and StoredClipSizeTest pins both against the limit so the class cannot come back quietly. Trimming under-skips a very long break rather than over-skipping into content, which is the safe direction. Verified on the emulator: 10 migration tests including the never-before-run 7->8, LibraryOrderTest, and the whole reported flow by hand -- bedtime split, timer arming and cancelling, the dimmed row, both readouts. Re-ran the exact sequence that crashed: three downloads, no crash, FingerprintWorker SUCCESS, and the MP3 decoder visibly running, which is the first time MediaCodecClipSource has executed on hardware at all. Still open, and recorded in CLAUDE.md rather than glossed: detection has not actually confirmed an intro or outro for a real show. Three Up First downloads left both ends "Nothing found yet". Possibly correct -- the threshold is strict by design -- or possibly the 8-second reference window against a roughly 6-second sting. That wants the desktop prototype, not a guess at a tuned constant. Coverage 91.76% -> 91.84%. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-26Extend Tier 3 fingerprinting to midroll ad breaksPeter Stone
Desktop-prototyped first, same discipline as the intro/outro half: three Darknet Diaries episodes spanning 2026 and one from 2017 were fetched fresh and cross-correlated. Beyond the show's fixed ~51s intro (present unchanged across all nine years), the same coarse-to-fine scan found a second, shorter segment recurring at a *different* timestamp in every episode's body and independently present in the 2017 episode too -- a real recurring house promo, not a coincidence of two fetches landing close in time. That two-episode, different-position, still-present-nine-years-later shape is exactly what a midroll break looks like and exactly what IntroOutroDetector was never built to find: it only ever compares fixed head/tail windows. Midroll detection means scanning a whole episode body, and a show can have several distinct breaks at once, not one fixed slot per end. - MidrollScanner: pure, single-resolution "find every segment two recordings share, wherever each has it" -- deliberately position-agnostic, built on the existing AudioCorrelation primitive rather than a new one. - Decimate: block-averaging downsample, the coarse half of a coarse-to-fine split. A brute-force native-rate scan across a full episode measured 30-160s per episode *pair* on a desktop CPU in the prototype; MidrollUpdater runs MidrollScanner once on ~8x-decimated audio to find candidates cheaply, then re-verifies each at full resolution with a single AudioCorrelation call -- both for a trustworthy score and to reject anything the decimation made look like a match but isn't. - MidrollDetector: IntroOutroDetector's confirm/discard state machine, generalised from one candidate per show to a list. A candidate an episode doesn't confirm is discarded outright, same discipline, same reason: the Darknet Diaries trap applies here too. - MidrollUpdater: orchestrates discovery and confirmation per show. Discovery compares the show's single rolling "pending" episode against whatever is newest; unlike ShowFingerprintEntity's pendingClipPcm, no audio is stored for it between runs -- a pending *whole episode* is tens of megabytes of PCM, not the few seconds an intro/outro clip costs, so the file is re-decoded from disk on demand and the pending slot just moves forward if it's since been reclaimed by retention. A real bug the tests caught: the same segment surfaces again every time the pending pointer rolls forward, which without dedup became a second row for content already tracked. Fixed by recognising a fresh discovery hit as already-tracked by *where* it landed in the new episode (MidrollDetector.allMatches), not by re-correlating extracted clip content, which two different coarse passes don't reliably extract the same sub-range of. - Storage: AppDatabase 7->8, a show_midrolls table (one row per candidate, not per show -- several breaks can be tracked at once) and an episode_midrolls table (the confirmed ranges detected per episode). EpisodeEntity gains midrollCheckedAtMs (per-episode, not a show cursor, so an out-of-order backfilled download still gets checked). SubscriptionEntity gains midrollPendingEpisodeId. - Playback: MidrollSkip mirrors AdChapters' shape exactly. PlayheadPolicy checks it unconditionally, same as intro/outro -- no per-show toggle, the confirmation threshold is the safety mechanism. PlaybackSources grows a midrolls() method alongside chapters() rather than threading a new DAO through PlaybackAdvancer's constructor, which would have pushed it over detekt's LongParameterList threshold for no real reason. - Trigger: MidrollWorker runs nightly while charging, not per-download like FingerprintWorker -- a full-body scan is too expensive to run eagerly, and unlike a download it never touches the network, so there is no wifi gate either. Peter's call. MediaCodecClipSource gains decodeFull, sizing the decode buffer from durationMs with a margin rather than the fixed 4-minute clip window decodeHead/decodeTail use. Coverage 91.70% -> 91.76%. Neither half of Tier 3 has run on a device this session -- see CLAUDE.md's known-gaps list. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sj3CoWEedM56bebByKgNxA
2026-09-26Tier 3 skip detection: audio fingerprinting, prototyped and builtPeter Stone
Closes the last deferred Phase 2 item. Desktop-prototyped first, per the design plan's own instruction, against three real shows (see the prototype findings recorded in memory): NPR Up First (a clean ~6s intro sting, r 0.96-0.998 across every pair tested), Tangle (a long 60-90s open/sponsor read, equally reliable), and Darknet Diaries (no real intro -- a segment that matched between two temporally-close episodes turned out to be a dynamically-inserted ad, confirmed by checking a 2018 episode that matched the same "intro" despite the multi-year gap). That last finding drove the actual design: IntroOutroDetector requires 3+ independently-fetched episodes to agree before trusting a candidate and auto-applying it, which is exactly what the Darknet Diaries ad would not have survived. The confirmation threshold is the safety mechanism, not just caution for its own sake -- and it's now a parameter, not a hardcoded assumption, after coverage review found the "still tentative" branch was unreachable at the original threshold. - AudioCorrelation: FFT-based normalized cross-correlation, a Kotlin port of the validated prototype. Tested against numpy/scipy-derived ground truth fixtures, not just self-consistency -- the prototype's own normalization bug (global mean instead of each window's local mean, producing a bogus 0.98 "match" plain correlation put at -0.02) is exactly what that guards against being ported here unnoticed. - IntroOutroDetector: the pure state machine (waiting/tentative/confirmed/ discard-on-mismatch), mirroring DripSchedule's shape. - MediaCodecClipSource: the one Android-only piece (MediaExtractor/ MediaCodec decode to mono 8kHz PCM), on the framework-adapter coverage exclusion list for the same reason PlayerConnection is. - Storage: AppDatabase 6->7, a new show_fingerprints table (one row per show per intro/outro, tracking the current candidate clip and how many episodes have confirmed it) and two new nullable EpisodeEntity columns. Tier 2's manual per-show offset remains the fallback -- PlaybackProgress itself is unchanged; DetectedSkip.kt is the entire integration surface. - Trigger: EpisodeDownloader gained an onDownloaded hook (every download route ends up there, manual or queued) firing a new one-shot FingerprintWorker per episode. Coverage improved (90.13% -> 91.70%): the new pure logic is fully tested, and closing two real gaps the coverage review surfaced (an untested first-two-episodes-disagree path, and ShowFingerprintEntity's hand-written equals/hashCode, needed because a ByteArray property breaks the generated one) accounted for more than the new Android-glue lines cost. Not yet verified on a device -- no phone connected this session. The MigrationTest 6->7 case and the actual decode/match pipeline both need a real run before this is trusted with real playback. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sj3CoWEedM56bebByKgNxA
2026-09-26Drop ID3 CHAP frames, having measured the real libraryPeter Stone
Peter asked how expensive it would be to check before dropping them. Checking turned out to be cheaper than arguing about it, so here is the answer from his own 61 subscriptions rather than from general knowledge. Every feed's newest episode was fetched far enough to read its entire ID3 tag -- the declared size from the header, not a guessed window, which matters because several are multi-megabyte (Cruising the Waves carries 10MB of cover art) and a first pass with a 400KB window left thirteen of them inconclusive. ID3v2.2's 3-byte frame headers needed handling separately from v2.3/2.4's 4-byte ones, which is what made Dan Carlin's feeds look truncated at first. Not one of the 61 carries a CHAP frame. The frames present are all artwork and metadata: APIC, TALB, TIT2 and friends. Exactly one show publishes chapters at all -- Wards Way Podcast -- and it does so as Podcasting 2.0 JSON, which has been supported since the skip work landed. So CHAP is dropped rather than deferred. The two survey scripts are kept under tools/ so this can be rerun in a minute if a new subscription ever suggests otherwise, rather than re-derived. Two things fell out of the survey worth writing down. A feed can carry two chapters elements per item -- the podcastindex one with a url and a podlove one without; Buzzsprout emits both. The parser takes the first non-null url so it is unaffected, but a last-wins read would silently lose chapters. And the one show with chapters marks none of them toc:false, so ad-chapter skipping -- on by default as of this morning -- currently has nothing to act on anywhere in the library. It is correct and idle, waiting for a publisher who marks breaks. Documentation and tooling only; no behaviour change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-26Hide played episodes, and hand off to the video at the right momentPeter Stone
Two of Peter's asks. Played episodes are hidden by default. An episode you have heard is done with, and leaving it in the list buries the ones that are not. A global toggle on the show screen brings them back, so a mis-tapped "played" can still be found -- stored in DataStore, which has been a declared dependency since phase 1 with nothing using it. Android Auto always hides them regardless of the toggle: a car list is scrolled blind at a traffic light. Hand-off carries the position both ways. Out: "Watch from here" opens the video on its own site at the point reached. In: a link shared from YouTube with a timestamp on it was shared at that moment deliberately, so the episode starts there rather than at the top. WatchLink does both and is pure, because an unrecognised URL shape fails silently -- as a link that simply starts from the beginning -- and YouTube produces a lot of shapes. The button sits on the episode screen as well as the player. That was not the first design, and testing changed it: the player-only version is unavailable exactly when it is most wanted, because an episode YouTube declines to serve never starts playing and so never reaches the player at all. Verified on a device, round trip: youtu.be/dQw4w9WgXcQ?t=90 shared in stored a position of 90000, the episode screen offered Watch, and tapping it fired youtube.com/watch?v=dQw4w9WgXcQ&t=90s. Marking an episode played removed it from the list, the toggle brought it back, and the preference survived a restart. An ordinary podcast offers no hand-off, having no page to open. Two things caught on the way. A crash: the episode cache was declared after the init block that collects from a StateFlow, which emits synchronously, so it was still null on the first emission -- caught by the launch check, now a plain field declared with the others. And the played switch needed a contentDescription distinct from its visible label, or it is ambiguous to pick -- the same trap the drip switch hit. Preferences joins the coverage exclusions: a Context, a file and a generated Flow, deciding nothing. WatchLink is fully covered. No permission changes, no schema change. 442 unit tests green, detekt and lint clean, coverage steady at 90.13. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-26Skip marked breaks by default, and drop the instrumented-coverage halfPeter Stone
Two things. Ad-chapter skipping is on by default, at Peter's request after living with it. It shipped off out of caution, being the one part of the skip feature that removes audio nobody has heard -- but it only ever acts on sections a publisher explicitly marked as not content, never on a guess from a title, and doing that without being asked each time is the point. Migration 5 -> 6 moves existing shows rather than leaving the new default to apply only to new ones. The column's SQL default stays 0 while the Kotlin default becomes true. Room writes every column on insert, so the SQL default is only ever consulted by an ALTER TABLE that has already happened; changing it would mean recreating the table for a value nothing reads. Said so at the field. Two unit tests were leaning on the old default to mean "not opted in". They now say `skipAdChapters = false` explicitly, which is what they meant. Second: enableAndroidTestCoverage is gone again, a day after adding it. It was the "both" half of the coverage change, and measuring it made the trade obvious -- it instruments every class in the debug build, which cost 25MB on the APK (118MB -> 143MB) plus runtime probes, on every sideload, for a report that would be run rarely. I had already published one of those builds. The framework-adapter exclusions carry this on their own; they are what moved the number from 65% to 90%, and what makes a future drop mean something. No permission changes. 428 unit tests green, 6 migration tests on a device, detekt and lint clean, coverage 89.97 -> 90.12. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-26Name a shared video from the extractor when its page will notPeter Stone
Facebook serves no og: tags to a non-browser at all, so a shared video was filed as "Video 10153231379946729". yt-dlp knows perfectly well what it is called, and is going to be run against that URL anyway. The probe only happens when the page gave no title: a title that was shared is the one that was meant, and this costs a network round trip. It is allowed to fail, because it buys a nicer name rather than anything the episode needs -- a video that cannot be probed still downloads and plays. Duration comes along with it, which Facebook episodes previously had to do without entirely. This retires the last entry but one from the reachability allowlist: YouTubeAudio.probe has been sitting there since phase 1 described as "the resolver will want it for episode durations", which turned out to be exactly right. Verified on a device: the same Facebook video that arrived as "Video 10153231379946729" this morning now arrives as "How to Share With Just Friends", 74 seconds. No permission changes, no schema change. 428 unit tests green, detekt and lint clean, coverage 89.93 -> 89.97. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-26Exclude framework adapters from coveragePeter Stone
Peter's call. The gate had been overridden eight times running, every one a class whose whole body is a call into ExoPlayer, Media3, Room, WorkManager or Chaquopy -- none of which execute on the JVM. The last few overrides were the argument for doing this. Pulling completion decisions out of PlaybackService into tested classes lowered the number, because the tested part was already counted and the glue it left behind was counted too. Writing an instrumented test for a migration lowered it again, because jacocoTestReport measures unit tests only. A metric that punishes both of those is measuring how much Android glue the app contains, not how well its logic is tested. Same code, 65.04% -> 89.93%: 629 uncovered lines were adapters, 131 are real. What is left is honest -- page-fetch coroutines that need the actual host, a few data classes, and Room's Converters, which are plain Kotlin and genuinely untested. A drop from here will mean something. The list sits next to its reasoning in the build file, with the bar written down: "cannot execute off a device", not "awkward to test". QueueRepository, PlaybackAdvancer, EpisodeDownloader and ChapterFetcher all touch framework-adjacent things and all stay measured. Also added jacocoFullReport, which folds in instrumented coverage for when you want the fuller picture with a device attached. The commit gate deliberately does not read it: device data only exists after a device run, so including it would make the gate report different numbers for identical code depending on whether one happened recently -- worse than a narrow gate. One trap worth recording: Kotlin block comments nest. A KDoc mentioning a glob with `/*` in it opened a nested comment, so the closing `*/` only closed the inner one and the comment ran on until it hit a `*/` inside a later string literal -- producing syntax errors on innocent lines well below the real cause. No behaviour change. 425 unit tests green, detekt and lint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-26Seed the default filters on a fresh install too, not only on upgradePeter Stone
Caught while verifying on a device: after a reinstall the rules table was empty. A fresh install creates the schema at the current version and never runs a migration, so putting the seeding only in MIGRATION_4_5 gave the trailer and preview filters to upgraders and silently to nobody else. Peter's own phone would have been fine -- it is an upgrade from v4 -- but a reinstall later would have quietly lost them, which is the kind of gap that surfaces as "I thought you set that up". The SQL is now shared between the migration and a RoomDatabase.Callback, and covered by an instrumented test building a fresh database and asserting both filters arrive. No permission changes, no schema change. 425 unit tests green, 11 instrumented, detekt and lint clean. Coverage 65.18 -> 65.04, exception recorded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-26Facebook videos, and a muxed fallback so they can be fetched at allPeter Stone
Individual videos, which is the whole of what is reliably possible: Facebook pages publish no RSS, so there is no equivalent of subscribing to a YouTube channel. Flagged that before building rather than quietly delivering half. A shared link is recognised across the shapes Facebook actually produces -- watch, page videos with or without a slug, reels, fb.watch and /share/v codes, on www, m and web hosts -- and lands in its own "Saved Facebook videos" container. Its own, because the container carries the kind that decides how its episodes are fetched, and one row cannot be honest about two sources. The guid is prefixed per source too, so a Facebook and a YouTube video that happen to share a numeric id stay separate episodes. The og: tag parsing YouTube already did privately is now OpenGraph, used by both, per the standing instruction about two solutions to the same problem. It also learned to tolerate either attribute order and single quotes, which real pages use. The download seam stops claiming to be YouTube's: yt-dlp takes a URL and works out the site itself, which is exactly what made this cheap. YouTubeDownloadSource is ExtractedAudioSource and ChaquopyYouTubeSource is ChaquopyAudioSource. One real find, and the reason the first attempt failed on a device: Facebook publishes no audio-only stream at all, only pre-muxed sd/hd mp4, so "bestaudio[ext=m4a]/bestaudio[ext=webm]/bestaudio" resolved to nothing and yt-dlp reported "Requested format is not available". _FORMAT now falls back to a muxed mp4. That is still a single already-complete file, so the no-ffmpeg rule holds -- what must never appear there is bestvideo+bestaudio. Media3 plays an mp4's audio track without complaint, and the data model has been media-agnostic since phase 1 for this sort of reason. Verified end to end on a device against a real public Facebook video: resolved to its own container with the right kind and guid, fetched by yt-dlp, and played. This also stands as the first proof of the previous commit's fetch-then-play path against a genuine extractor rather than a direct URL, since YouTube itself is bot-blocked from this machine. Known gap, not fixed here: Facebook serves no og: tags to a non-browser at all -- checked with the app's own user agent -- so a shared video shows as "Video <id>". yt-dlp knows the real title, and YouTubeAudio.probe is still sitting unused in the reachability allowlist waiting for exactly this. Small follow-up. No permission changes, no schema change. 425 unit tests green, detekt and lint clean, coverage 64.90 -> 65.18. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-26Rank shows by what is still waiting, and seed the trailer filtersPeter Stone
Two of Peter's asks. Ordering. A show ranked by its newest unplayed episode, so queueing something left the show sitting at the top with nothing actually waiting -- his words, once they're on the queue they're noise in the list. Queued episodes are now excluded from the ranking for the same reason played ones already were: they have been dealt with. That is one clause of SQL, and the fake DAOs return insertion order, so it is exactly the kind of rule that cannot be pinned off-device. LibraryOrderTest runs against a real in-memory Room database and covers the change, an older unqueued episode still counting, played episodes still not counting, and a show with nothing waiting sorting last rather than vanishing. Confirmed it fails without the fix before keeping it. Filters. Peter chose the existing rule engine over anything new, with the rules set up for him -- and rules live in the database on the phone, so a migration is the only way to put them there. MIGRATION_4_5 seeds "trailer" and "preview", both mark-played, as ordinary rules: they show up in the Rules screen and can be edited, disabled or deleted. Appended after whatever is already there rather than taking position 0, so they cannot pre-empt rules already relied on, and INSERT OR IGNORE on a fixed id means deleting one does not bring it back on the next upgrade. They act only on newly discovered episodes, so nothing already in the library changes. One judgement call worth flagging: "preview" will catch a sports show's legitimate Season preview. It is two taps to delete if it bites. Schema 4 -> 5, no shape change -- the migration only inserts rows. Covered by two instrumented tests: seeding after an existing rule, and a 1 -> 5 chain landing exactly two. No permission changes. 403 unit tests green, 10 instrumented, detekt and lint clean. Coverage 65.20 -> 64.90, exception recorded: the new code is a migration tested on a device, and the coverage report counts unit tests only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B