summaryrefslogtreecommitdiff
AgeCommit message (Collapse)Author
10 daysOne tap to play, and a way to squeeze the next drip episode outPeter Stone
Two things Peter asked for, both about a cadence the app imposed and gave no way around. **Opening the app and pressing play was two taps.** The library's Up Next row had no play button: tapping it opened the queue, and the button was there. Two taps for the single thing the app is for. The row has a play button now and still opens the queue, which is where reordering and everything else lives. **A slow drip had no override.** A drip is a cadence you chose so you would not have to think about it, and most of the time that is the whole point -- but a cadence you cannot override owns you. Finish Tuesday's episode when the next is due Thursday and there is nothing to listen to and no way to ask for more. "Release the next one now" sits under the drip cadence in the show's settings, and only while drip is on: a show listing everything already has nothing to bring forward. It honours the show's batch size and its oldest/newest-first choice, because those are the same decision. One judgement in it, and the opposite choice is defensible. It **brings the next release forward** rather than granting an extra: the mark moves to now, so the scheduled one is a full interval away instead of landing on top of this one. Asking for the next episode reads as asking for it sooner. The clock restarts even when the archive turned out to be empty, so repeatedly asking an exhausted show cannot build a debt that bursts when the publisher next posts -- the same reasoning DripSchedule already applies to the scheduled path. An empty archive says so. "Nothing happened" and "the button is broken" look identical otherwise, which is the same fault the drip toggle's own banner was written to avoid. DripSettings' callbacks are bundled into DripActions, because adding the fourth took it past the parameter limit -- the same shape EpisodeActions and LibraryNav already use. Checked on the emulator, not just in tests: the play button appears on the Up Next row and starts the episode, and the release button queued episodes and advanced the drip mark. 1031 tests, detekt 0, lint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
10 daysThe car plays our copy too, and the transcript URL stops leaking the tokenPeter Stone
**Android Auto was the one caller still streaming from publishers.** `onAddMediaItems` resolved a browse id straight to a media item without asking whether the audio existed, so an undownloaded episode was handed to the player as its publisher's URL. That is the worst place for it: the car is where most of this library is actually heard, and misplaced skips are least obvious there. It goes through `PlaybackAdvancer.startFetchingIfNeeded` now -- start, and fetch first if there is no file -- the same decision the in-app paths make, in the one class tested for it. A fetch that fails stays failed rather than falling back: the publisher's copy carries different advertising, so the server's measured breaks land in the wrong place, and silence that can be retried beats skipping into the programme. With that, nothing reached the fallback, so it is gone. `setUri(localPath ?: mediaUrl)` is now `setUri(localPath)`, `MediaUrl` is deleted, and OutboundRequestsTest asserts zero remote media items rather than pinning one. `start` also reports Gone for an episode whose show *and* file have both gone, which is what made the fallback removable -- an orphan that still has its file plays. **Two findings from the security review of the last commit, both mine, both real:** - `Request.Builder().url()` throws on a URL it cannot parse and only IOException was caught, so a feed with a junk enclosure threw straight past the Result its caller folds. Survivable when this ran from a worker; not now that every play goes through a fetch, where one bad enclosure in one feed would stop any episode starting. Caught in the downloader, and `fetchAndStart` turns a thrown fetch into FetchFailed -- rethrowing CancellationException, which a test now pins, because swallowing it would report a cancelled start as a failed one and leave a false error on screen. - `FeedFetcher` logged whole feed URLs. Private feeds carry a token in the query and some carry credentials in the userinfo. `FeedFailureText.redact` cuts them to scheme and host, and is tested as the security control it is rather than as tidiness. The unparsable branch logs neither the address nor the exception, and says why: that exception's message *is* the address, so logging the cause would put back exactly what redacting removes. **The transcript URL no longer carries the token.** Apache logs the request line, so every /api/transcript fetch wrote the server's token into its access log in plaintext, across fourteen rotations. It was redundant there: the phone sends Authorization: Bearer anyway and tokenOK takes either. The audio URL still carries one -- not redundant, the downloader has no credentials of its own -- so removing that needs an app change installed before the server stops emitting it. The gate refused this twice and was right both times. The coverage drop was not all deletion: the redaction and the cancellation contract were genuinely untested, and have tests now. Then detekt caught an import left behind by moving the redaction into FeedFailureText -- a move I made after the last time I ran it. 1031 app tests, 376 server tests, detekt 0, lint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
10 daysSay what happened in words, and stop streaming from publishersPeter Stone
Three things, from one red banner on Peter's phone reading "Playback failed: ERROR_CODE_IO_BAD_HTTP_STATUS". His words were "arcane and sketchy". Both halves were fair, and the second was pointing at a real bug rather than a wording problem. **The app was streaming episodes from their publishers.** `toMediaItem` falls back to the enclosure URL when there is no local file, and nothing stopped a play reaching it: press play on an undownloaded episode and it opened the publisher's CDN directly. Three things wrong with that. It is the automatic publisher fallback the download path had removed on purpose in October ("Do not reintroduce an automatic fallback for a transient failure"), surviving where nobody looked. It is an outbound request to a third party that nothing asked for -- the sketchy part, and Peter was right to read it that way. And worst, nothing in playback checks `audioFromServer`, so the breaks the server measured on *its* copy were being applied to a copy with different advertising in different places: skips landing in the wrong spot with no way to tell why. `PlaybackAdvancer.start` now returns NeedsFetch for any episode with no file, which routes through the download path that already honours the server rule. One caller still reaches the fallback -- Android Auto resolves a browse id straight to a media item without the advancer, and a car that silently plays nothing is worse than a car that streams. That needs fetching inside the session callback; until then OutboundRequestsTest pins the fallback to exactly one line. Ruled out first, with evidence rather than theory: the skip server (every request in its access log is a 200), the user agent (both the app's and Media3's get 200 from the enclosure), and my own deploy the day before. **The messages.** Media3's constant is now a sentence (`PlaybackFailureText`), and it was not the only one. The same pass found `Download failed: ${it.message}`, `Could not read that file: permission denied`, and `FeedFetcher` putting the literal word `UnknownHostException` under a podcast's name in the library -- that one reaches two surfaces, because the text is also stored as the subscription's `lastError`. **Two existing tests asserted the machine text came through**, which is how it survived review; they assert the opposite now. Accuracy turned out to be the harder half. "It will be retried" reads better than "Try the download button again" and is a lie: a failed download is deliberately left FAILED. A 404 and a 503 both mean "it didn't work" to a library and are different decisions for the person choosing whether to unsubscribe, so they say different things. Where the cause cannot be narrowed the sentence stays vague rather than guessing. Every cause goes to logcat -- which detekt caught me failing to do in FeedFetcher, three swallowed exceptions after I removed their messages. **The guardrails Peter asked for**, both watched failing before being trusted: - `PlainLanguageTest` -- no listener-facing message may be built out of an exception or spell out an error constant. Deliberately scoped to where errors surface rather than judging all prose. - `OutboundRequestsTest` -- one OkHttpClient, built in AppGraph, carrying SecureSchemeInterceptor; nothing may open a connection another way; and the publisher-stream fallback must stay at one site. The same arrangement the server has in safefetch/audit_test.go and for the same reason: this app fetches URLs chosen by podcast publishers. **And the readout, which Peter asked for directly.** The per-show "Detected skips" lines read the confirmation rows the on-device detector used to write, so since detection moved they could only ever say "Nothing found yet" -- on every show, including ones the server was publishing breaks for. Counted off the episodes now. Verified on a device against a staged state rather than argued: three of five episodes carrying a detected intro, one carrying an ad break, reading "Found in 3 of 5 episodes" / "None found in 5 episodes" / "Found in 1 of 5 episodes". *Not checked* and *checked, found nothing* are different answers again, which was the whole point of the readout. 1016 tests, detekt 0, lint clean. Coverage 94.58% -> 94.12%, COVERAGE_EXCEPTION for the same reason as yesterday: the deleted confirmation-row code was covered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
11 daysDelete on-device detection. Detection is the server'sPeter Stone
Peter's call: server first. The audio-fingerprint subsystem ran on the phone until detection moved to the server, and then kept being shipped: FingerprintWorker and MidrollWorker went at the time and the twelve classes they drove stayed, unreachable, in every APK since. ReachabilityTest found it on 2026-11-03 once it was widened to classes, and it has been sitting in that test's allowlist with its reasoning since. Gone: IntroOutroDetector, AudioCorrelation, MidrollScanner, MidrollDetector, MidrollUpdater, FingerprintUpdater, MediaCodecClipSource, Decimate, Fft, MonoResampler, PcmCodec, StoredClip, and the 67 unit tests over them. 3,092 lines. **Recoverable by name, not by archaeology.** The tag `on-device-detection` marks the last commit that has it, pushed to the local remote along with everything else from this session, and the tag message says how to bring it back. The work cost weeks and the approach may be wanted again. What stayed, because none of it is detection: SkipSummary words the readout, BreakTrack and BreakPopup draw and explain the timeline's marks, and MidrollClipStore shrank to deleteAllFor alone -- its write and read halves had one caller each and both were the deleted updater, but a phone that ran the old version still has clip files on disk and unsubscribing is the only thing that clears them. Its test now writes the old layout by hand, because that layout is a compatibility concern now rather than this class's own choice. IntroOutroDetector.CONFIRM_THRESHOLD and MidrollDetector.CONFIRM_THRESHOLD moved to SkipSummary, the only thing left that reads them. Both were three and the server's DefaultConfirmThreshold is also three, checked against each other before the move. **The database was deliberately not touched.** show_fingerprints, show_midrolls and episode_midrolls are the server's columns now -- ServerSkips writes its answers into exactly the columns the on-device detector used to, which is why playback needed no changes when detection moved. Four MidrollDao methods went because they were the discovery half (upsertCandidate, deleteCandidate, getUncheckedDownloaded, setMidrollCheckedAtMs) and the reachability check named them; a DAO method is not schema, so there is no migration here and nothing for a real library to go wrong over. Two things found while doing it, neither caused by it, both written up in CLAUDE.md: - **The per-show "Detected skips" readout can never say anything.** It reads candidate and confirmation rows, and nothing has written one since detection moved -- the server writes per-episode ranges and offsets instead. Verified on the emulator, not inferred: a fresh install with a live subscription shows "Nothing found yet / Nothing found yet / None found yet" while the server is finding and publishing breaks for that very show. It has been lying since the migration; deleting the detector only makes it undeniable. - **RetentionSweeper's pending-episode guard protects nothing**, for the same reason: nothing sets a pending basis any more, so both id sets are always empty. The APK barely moves -- 110KB of dex, 72.61MB to 72.50MB -- because it is dominated by the embedded Python. This was never going to be a size win and is not being sold as one; it is 3,092 fewer lines to read, search and mislead. 1004 tests pass, detekt 0, lint clean. Coverage 95.35% -> 94.58%, which is the documented COVERAGE_EXCEPTION case: deleting dead code that happened to be well covered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
12 daysLook again for a waiting episode, and place a library nobody has placedon-device-detectionPeter Stone
The last two pieces, both of a kind: a feature that was built, wired and correct, and did nothing because the thing that triggers it was missing. **"Waiting for server" lasted until the next refresh.** The server starts fetching the moment it is asked and its on-demand mirrors finish in seconds -- 183 of them over thirty days, several completing two or three seconds apart. The phone looked again only on a feed refresh, which is the 5am alarm, a launch or the button, so an episode the server would have within the minute read "waiting for server" for hours. That gap, not anything on the server, is what made "not checked yet" a state a listener sees. A wait now schedules one look back a minute later, through WaitingDownloads, which already bounds the work two ways. KEEP rather than the REPLACE downloadNow uses: several episodes landing in the waiting state one after another is the normal case, and REPLACE would push the retry a minute further out each time until a queue of them starved it. The first wait sets the time. The hook lives on ServerSkips rather than on a wrapper around it. The wrapper version was written first and is the sort of thing that rots silently: changing the graph's declared type back to ServerSkips drops the override, nothing fails, and the retry simply stops. WaitForServerWiringTest now pins that a wait is reported through the real class, and was watched failing without it. **Tiers were shipped empty.** The control, the push on every refresh, the server ordering its work by it -- all built, all tested, and not one of 43 shows ever placed, because placing them was a tapping job. So the server worked the library in feed order and the news waited behind whatever else turned up, which is the exact thing the feature was asked for. TierSeeder reads it off the feeds instead, checked against the real library before it was written. It puts Tangle, Up First, Science Friday and Wards Way in Topical; both Dan Carlin shows, Radiolab, Reply All and the bulk-published back catalogues in Whenever; and leaves the weekly middle -- 23 of the 42 -- alone. Peter named Up First and Tangle as the ones to hurry and Dan Carlin and Radiolab as the ones to let wait, so the rule agrees with the person it is for. Publication rate alone does not work, which is why there is a floor. Serial, LOW and The Outer Reach each measure a median gap near zero because a whole season went up at once: on rate they look more topical than the news and mean the exact opposite. A gap under half a day is a back catalogue, not a daily show. It is a seed, not a policy. It runs once, touches only shows still sitting at the default, and never a show placed by hand -- and it declines to spend its one run on a library with no episodes to judge yet, or a fresh install would be marked done having placed nothing. Afterwards the control in the show's settings is the only thing that decides. Verified on the emulator rather than argued: Darknet Diaries, fortnightly, placed itself as "Whenever" on a refresh with nothing tapped. RetryWaitingWorker joins the three WorkManager entry points already excluded from coverage, under the list's own rule -- the decisions are WaitingDownloads', and those are measured. 1071 tests, detekt 0, lint clean, coverage 95.29% -> 95.35%. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
12 daysWire up the sleep timer's second mode, and make a duration extendPeter Stone
Peter's call on both, after the view-model work surfaced them as dead code. SleepTimer was documented as working "in two modes: a countdown, or stop at the end of the current episode", and armForEndOfEpisode, shouldStopAtEpisodeEnd and extendBy had no caller anywhere. So "stop at the end of this episode" did not exist in the app, and every duration in the sleep dialog went to armFor, which resets -- tapping 15m on a timer with thirty seconds left gave 15:00 and quietly took those thirty seconds away, the one thing extendBy's own comment says not to do. A duration now adds to a running timer and the buttons say so, "+15m" rather than "15m". The dialog offers "End of episode", and the overflow entry reads "Sleep timer - end of episode" in that mode, since there is no countdown to show. The two modes watch differently on purpose. A countdown needs a clock, so it ticks. Stopping at the end of an episode needs the moment the episode is done, and a one-second poll means a second of the next episode playing to somebody who asked to be stopped -- so it follows nowPlaying, which already republishes every second for the progress bar, and treats the queue advancing as the end whatever the last position said. Adding the mode took PlayerViewModel past its function limit, so the whole concern moved to SleepController -- the answer ListenViewModel got when it outgrew one, rather than raising the threshold a third time. SleepTimer keeps the arithmetic and stays pure. ReachabilityTest now covers classes, which is why this was invisible: it scanned `object` declarations only, and SleepTimer is a class. Classes cannot use the object check's matching -- a call names the instance, not the class -- so they get the DAO check's looser `.name(` matching, which needed three call shapes before it was honest: the ordinary call, a `::name` reference, and a trailing-lambda call with no parens at all, which is how OpmlViewModel reaches importFrom. It found one more thing, allowlisted with the reason rather than hidden: **on-device detection is dead code.** FingerprintUpdater and MidrollUpdater are never constructed -- every reference from outside their own files is a KDoc cross-reference -- and through them IntroOutroDetector, AudioCorrelation, MidrollScanner, MidrollDetector, MidrollClipStore, MediaCodecClipSource, Decimate, Fft, MonoResampler, PcmCodec, StoredClip and BreakTrack, about two thousand lines shipped in every APK. The workers went when detection moved to the server; the classes stayed. Deleting a subsystem is Peter's call, so it is recorded in the allowlist instead. Two smaller things found on the way. EpisodeDownloader's wait notification went onto ServerAudio, where the rest of the server's side of a download already lives, rather than becoming a seventh constructor parameter -- and the first attempt added it *after* the `now` clock, which silently rebound every existing trailing-lambda call site from the clock to the new hook while still compiling. The UnusedImports rule enabled earlier today caught a stale import in this very commit. 1057 tests, detekt 0, lint clean, coverage 95.17% -> 95.29%. Checked on the emulator: the dialog offers End of episode, and arming 15m shows "Sleep timer - 14:52" on the overflow entry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
12 daysMeasure and test the view models the ui exclusion was hidingPeter Stone
The coverage gate excluded `**/ui/**` wholesale, justified as "Compose screens are impractical to unit-test". It also hid six view models -- plain Kotlin classes holding real decisions, every one at 0%, not one of them a screen -- behind a justification that only ever applied to screens. PlayerViewModel and ListenViewModel were 150 lines of untested logic the gate could not see. Done by a subagent; its coverage figure, test count and detekt result were re-derived here from the XML rather than taken on trust, and its two findings checked against the source. The exclusion now names each screen file instead of the package. All six view models go from 0% to 92-99%, the measured `ui` package to 96.6%, and the project to **95.17% against a 94.44% baseline** -- the gate passes with the exclusion narrowed and no COVERAGE_EXCEPTION. 96 new tests, suite 951 -> 1048. The tests drive the real SubscriptionRepository, PlaybackAdvancer, QueueRepository, DripReleaser and FeedResolver against the existing FakeDaos and MockWebServer, so the banners asserted are the app's own wording rather than a stub agreeing with itself. Four production seams, all behaviour-preserving: `UiGraph` and `ViewPreferences` over the graph, `PlaybackControls` over PlayerConnection (the same shape as the `Playhead` precedent), and a second constructor per view model with the `(Application)` one kept as a delegate. That last is load-bearing in a way nothing would catch: `by viewModels()` finds a constructor reflectively, so losing it throws on screen open with a green build and no failing test. ViewModelConstructionTest is the guard. Its first version checked for the literal ": AndroidViewModel" and so missed TranscriptViewModel, which wraps its supertype onto the next line -- a check that would have passed while leaving a seventh view model unguarded in silence. It now cross-checks the two ways of finding them against each other. The action and state objects are excluded on a different ground from the screens, and the comment says so explicitly so the list does not grow by analogy: they construct fine off a device, but every line jacoco counts in them is one Kotlin generated -- constructor, componentN, copy, equals, hashCode, toString. Measuring them measures the compiler. A data class that gains a method or an init block comes back off the list. Also, found while reviewing: detekt's `UnusedImports` rule is off by default, which is how 71 dead imports accumulated across nine files. EpisodeActions.kt was eleven lines of data class under forty of them -- Scaffold, SnackbarHost, LazyColumn and the rest, left behind when the screens were split per file, which made it read like a screen. The rule is on and the 71 are gone. Two in that file needed removing by hand: detekt whitelists `getValue`/`setValue` unconditionally because it cannot tell a `by` delegation from none, and that file has no delegation at all. 1048 tests pass, detekt 0 findings on a fresh report, lint clean, coverage 95.17%. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
12 daysKeep what the listener asked for, and publish ads only from a publisher's masterPeter Stone
Two changes to the server, both measured against the live data rather than reasoned about. **Subtraction publishes only from a publisher's transcript.** Subtracting a publisher's transcript recovers 94-104% of the advertising the file lengths imply; subtracting a refetch -- a second download of the same assembled file, carrying its own ads in its own places -- recovers a median of 33%. That tier had already been tried and rejected at 21% and 0%, but nothing in the publish path was told which master it had, so it reached the phone anyway on seventeen ad spans. Written as a denylist first, and measuring it against a copy of the live data showed why that was the wrong shape: sixteen episodes carry a master whose label is blank -- the field postdates the first masters written, and Reclaim restored those files after the library was destroyed with the blank intact -- and they published on the strength of not being the one named bad kind. They happened to be publisher transcripts, so the answer was right by luck. It is an allowlist now, and the blank labels are repaired from the master files, which have said "published" all along. Measured on a copy, not predicted: before 49 ad spans (17 refetch, 24 unlabelled, 8 published) + 604 cue, 245 episodes after 62 ad spans (all publisher-master) + 611 cue, 245 episodes No episode loses its skips; the refetch ones trade advertising nobody could measure for the show's own recurring audio, which is a preference rather than an unconditional skip. Measuring also caught the recurring pass blanking the refusal subtraction had just written -- both passes answer "why is nothing published" through one field and recurring runs second, so all thirty-two episodes that now carry a reason were losing it a moment later. **Being asked for keeps an episode mirrored.** Retention was publication date alone, and pruning calls words.Forget, which deletes the transcript *file*. So an episode outside the newest eight was fetched on demand, recognised, served, and deleted on the next poll -- and asking again paid the whole cost over. From the server's own journal over twenty-one days: 397 transcription runs covering 309 distinct episodes, with 7.7 of the 33.4 hours of whisper time spent on episodes it had already recognised and thrown away. 23% of the budget, re-doing work it had discarded. Working through a back catalogue -- the archive tier, the slow-drip shows, the listening this was built for -- therefore paid full recognition for every episode and again to resume one. The window still decides what is worth fetching; RequestedAtUnix now decides what is worth keeping, stamped on every /api/episode call including for episodes already mirrored, because resuming something played last month must not let it fall out mid-listen. Kept for 28 days after the last ask, the same span refetchAfterSeconds uses, being the same kind of judgement about how long something stays interesting. Disk points the same way: transcripts are 36MB for 445 files, audio is 17GB for 336 episodes, and the volume has 365GB free. The expensive artefact is the small one. 374 server tests pass. Not yet deployed; the phone's half of this -- a pending reply is retried only on a feed refresh -- is still to come. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
13 daysWrite down what the corpus actually says, and correct three stale claimsPeter Stone
The findings doc's last entry was 2026-10-19 and ended by naming what was missing: "a mechanism that can tell a real short ad from a short coincidence". Words are that mechanism, it was built, and thirty-two server commits later the doc never recorded what it measured. A future session would have re-attempted the ad/furniture classifier that was already measured and deleted. The new entry is derived from the live state file rather than quoted, which is how it caught that publish.go's own figure describes a subset. "92% to 104% across eighteen episodes" is the publisher-transcript number. Across the 24 episodes now publishing an ad span, recovery runs 20% to 146%. Split by where the master came from, the spread stops being a spread: a publisher's transcript gives 94-104%, a refetch gives a median of 33%. Nothing in Publishable looks at which it has, and refetch masters are now fifteen of those twenty-four -- the majority of a tier the design had decided not to act on. It also caught me getting a conclusion wrong. Two Reply All episodes publish ad spans at 146% and 138% of their length gap, which reads as the detector over-claiming by half a minute of programme. Looking at the show instead of the episode: Reply All's gaps run from -0.48 to +1.02 minutes across eight episodes. They straddle zero, so the feed's declared durations are not a baseline for that show and the thing being exceeded is noise. The structural finding is the useful one -- HasInsertedAdvertising asks only whether this episode's gap clears one ad unit, and has no notion of whether the show's gaps are consistent enough to mean anything. Both versions are in the entry, because the wrong one is the one a reader would reach for next time. Nothing here changes behaviour. Whether a refetch should publish an ad at all is Peter's call and is written up as such. CLAUDE.md, separately: - It still said the episode description shows about two lines. That was changed to ten yesterday, for a reason that is recorded: the limit existed to stop long notes pushing the transport off-screen, and the merge had already put the notes below the transport. Left as it was, the doc was an instruction to reintroduce the bug. - "207 unit tests" has been wrong for most of the project's life; the suite is past nine hundred. Replaced with a note to ask the wrappers, since any number written here goes stale the same way. - The TDD rule banned backfilling tests onto existing code without qualification, which also forbids the one case where it is right -- pinning behaviour before moving it, so a refactor is provably behaviour-preserving. Narrowed to say that, and to say the order matters. The sixteen formatter tests in 2cc252a were written that way. - Added the rule those formatter files were moved to satisfy: anything the app says out loud lives in a named file of its own and is tested, never inside the composable that renders it. It was already followed by eight files and stated only in WaitingForServerText's own comment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
13 daysThe empty queue says it is empty, and Up Next is one namePeter Stone
Two things found by walking the queue screen, which was the one screen the UI pass had not reached. It was the only list in the app that went blank. Every other one explains itself when there is nothing to show -- EmptyLibrary, EmptyBedtime, EmptyRules, the chapter list, the transcript -- but removing the last episode from the queue left a back arrow, a title and nothing, which reads as a screen that failed rather than one with nothing in it. Worse, the empty queue screen is only reachable *by* emptying it: with nothing queued the library hides its Up Next heading and the now-playing bar is absent, so there is no way in. Blank was therefore the only state that screen ever showed after the last removal. It now names the state and says where episodes come from, since none of the three ways in are on that screen. Test written first and watched failing on the missing text, not on a compile error, then confirmed green; then read back off the emulator by queueing an episode, opening the queue and removing it. The second is wording. The queue is called "Up Next" in the Android Auto browse tree, in the queue screen's title, in the now-playing bar's icon and in the banner after queueing something -- and "Up next" in the library heading directly above the row it describes. One name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
13 daysMove what the app says about time out of the views that said it firstPeter Stone
formatClock lived in NowPlayingBar.kt and had grown five callers across screens that share no other code -- the transport, the break timeline, the chapter list, the sleep timer and the transcript gutter. progressLabel, the one line under every episode title, lived in EpisodeRow.kt and had three. Both are pure, neither is a view, and each file had defined its own minutes-per-hour, so changing how the now-playing bar reads a position meant editing the transcript gutter's formatter. They are the last two holdouts from a rule this app already follows everywhere else: anything it says out loud lives in a named file of its own and is tested, not inside the composable that renders it. SkipSummary, RuleSummary, FoundBySummary, WaitingForServerText, DescriptionPreview and SpeedChoices are all already arranged that way -- WaitingForServerText's own comment says so. Compose screens are excluded from coverage, so wording left inside one is wording nothing checks. TimeText holds both formatters and one set of constants: formatClock for a position on the transport, formatDuration for a length. They look similar and are not interchangeable -- 1:02:03 answers "where am I", 1h 2m answers "have I got time for this". EpisodeLabels holds progressLabel and the release-date wording it uses. Both had no test at all. The sixteen here were written against the old locations and confirmed green *before* the move, so re-running them after it is what proves the move changed no behaviour; written afterwards they would only prove the new code agrees with itself. They pin the hour boundary from both sides, because everything in this library is under an hour except the thing the app exists for, and the future-dated episode, which floors to zero days and would otherwise read "today" for as long as it existed. 951 unit tests pass, detekt clean, coverage unchanged. The one line under an episode title was read back off a device after the move: "20 Oct 2026 · 1h 20m". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
13 daysGive the ad slot's length one homePeter Stone
Four packages each wrote 15 seconds for the length of an advertising slot -- skips, transcript twice, and signature -- and every copy carried a comment saying it agreed with the others: "the same unit skips.Classify uses", "the same figure the length-gap rule uses", "one ad unit from the start". The agreement was real and deliberate, and recorded only in prose, so a corpus that revised the figure would have moved one copy and left three disagreeing silently. internal/advertising holds it once. Each site keeps its own name and its own explanation of what the unit means there, because those genuinely differ; only the number is shared. The consolidation on its own fixes the four copies that existed. The audit test refuses the fifth: it walks the server's production source and fails on the number written as a literal, in either of Go's spellings. Watched failing on both before being trusted. Same arrangement as safefetch/audit_test.go, and for the same reason -- the instance that was noticed was never the whole of it. No behaviour change: 366 server tests pass, same as before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-11-01A transcript line says when it was saidPeter Stone
Reviewing the transcript screen on a device: a wall of paragraphs with no times in it. That undercuts the whole reason the screen exists -- finding the bit you half-heard at twelve minutes -- and leaves a reader no sign that a line can be tapped at all, since plain text suggests nothing. A fixed gutter so the times form a column instead of ragging in and out with the words beside them, wide enough for "1:02:03" so an hour-long episode does not shunt the text sideways. Quieter than the words, because it orients rather than being the thing read. Tapping it seeks, like tapping the words: the line is one target. formatClock rather than a fourth time formatter -- this app already had three. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-11-01Settings: switches that line up, and a label that fitsPeter Stone
Reviewing the settings screen on a device. The two switches did not line up with each other: SwitchRow centred the switch against its whole text block, which looks right while every label fits on one line and goes wrong the moment one wraps. The second switch sat lower than the first for no reason a reader could see. Top-aligned now, so the control is level with the first line of what it controls whatever the text does. "Race through breaks at 3x instead of skipping" was the label that wrapped. "instead of skipping" is gone from it -- the detail underneath already draws that contrast, in better words -- and the multiplication sign matches the speed chip, which has always used one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-31Let an episode's notes use the space the transport freedPeter Stone
Reviewing the episode screen on a device: the notes are cut at 140 characters and the bottom three-fifths of the screen is empty. The two-line rule was protecting something real -- long notes used to push the transport off the screen. That reason went away when the player and episode screens merged and the notes moved *below* the transport. Nothing is underneath them now, so there is nothing left to push away, and the rule had been quietly costing the screen ever since. Ten lines, which is what the gap the old rule left behind would hold, and the tap still opens the rest for anything longer. Three tests failed on the change, all fixtures with the old budget baked into their text -- "First. Second. Third." for "a third line is cut". Their intent was always "more than the budget is cut", so they are now sized from the constant and cannot rot the same way again. The case the old rule got wrong -- a description that fits the budget being shown whole -- is now a test of its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-31Close the reachability guard's blind spotPeter Stone
It matched `fun` at four spaces, so every `internal fun` on a helper object was invisible to it -- which is most of what this app's UI layer exposes to its own module. The guard that exists because findFeedLink and QueueRepository.advance were written, tested, documented and never called could not see that whole category. It also credited only cross-file callers, so a helper called by its own object's public entry point looked dead and needed an allowlist entry explaining that it was not. Same-file callers now count, with declarations stripped first so a function cannot find itself -- the trick the DAO half of this test already used. Both together: a wider net and one fewer exception. The allowlist is down to a single entry. Watched failing on a deliberately dead `internal fun` before being kept, and watched passing with the entry removed -- so the widening caught nothing that was actually live. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-31Gate the server too, and stop reading go test with grepPeter Stone
The commit hook had nine steps and not one of them touched the server. Every server change for a week -- twenty-five commits, including the ad detectors and the thing that publishes skips -- went in on somebody remembering to type "go test" and reading the output. That is exactly the habit the Gradle wrappers exist to replace, in the half of the repo nobody had wrapped. go-verify reports from exit codes and parsed `go test -json`: gofmt, build, vet and a real test count. It was watched catching both failures that look like success in a terminal -- a failing test, named; and a package that fails to compile, where go test exits non-zero with no failing test and the count silently drops from 366 to 356. 366 is a number I did not have until now, because "go test ./... | grep -v ok" cannot produce one. It runs first in the hook, because ten seconds before the Android build beats several minutes after it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-31Stop a second device from emptying the library, and recover what one didPeter Stone
I pointed the emulator at the live server to verify that published breaks actually skip. It worked. It also pushed the emulator's own four-show subscription list, which replaced the real one, and at 07:07:13 the server reclaimed every show that device had never heard of: 26 shows and 290 mirrored episodes, deleted in a second. Three fixes, in the order they matter. **The list can no longer be emptied by one push.** It is still replaced wholesale -- the phone owns it, and reconciling two editable copies of something changed this rarely costs more in sync bugs than it saves -- but a push that keeps fewer than half of ten or more feeds is refused with a 409 and logged. Losing most of a library in one push is a second device or a bug; it is never somebody unsubscribing. Below ten feeds there is nothing to protect: a new phone's first push legitimately replaces nothing with everything. **Recognition is recovered rather than redone.** A transcript is keyed by the episode id, which comes from the feed and the guid, so it outlives the record and matches again when the episode is mirrored afresh. 332 transcript files survived on disk. Reclaim restores them -- 18 immediately, the rest as re-mirroring reaches them -- instead of spending twenty hours of CPU recognising text the server is already holding. It refuses a transcript whose last cue runs past the end of the audio, which is the same check made of a publisher's. **The pipeline's Save was never wired**, which the security review caught before it could panic in anger: the extraction moved the call and not the field. It is now optional, because every test wants it nil, and assumed-non-nil is what shipped a server that would have died on its first recognised episode. Reclaim runs before each show is polled as well as at startup, or a re-mirrored episode would wait for a restart to find the words already sitting next to it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-31Deep clean, part one: get the pipeline out of the commandPeter Stone
cmd/listend/main.go had reached 1,249 lines carrying nine passes, and cmd has no test file -- so every judgement in the transcript pipeline lived where nothing could reach it. That is not hypothetical: a refetch master blocked the recurring detector for a fortnight because the condition was wrong in a place no test could see. internal/words now holds the five passes with judgement in them -- Harvest, Recognise, Rederive, Recurring, SecondCopy -- behind a Pipeline that names its dependencies. main.go is 631 lines and does what a command should: read flags, wire things together, serve. **Moving it found a latent bug.** Pruning an episode's audio cleared its transcript fields but not its master ones, so a re-mirrored episode would claim a master whose file had been deleted -- a second description it could not produce, and subtraction finding nothing for it for ever. Exactly the shape of the bug that had just cost a fortnight. Pipeline.Forget now clears all of it, including the attempt timestamps, so a re-mirrored episode is done again rather than skipped for ever. Pinned by a test that asserts it needs recognising afterwards. Also in this part: Gradle's file-system watching is off. Chaquopy extracts a 3,200-file Python environment into app/build mid-build and the watcher tripped over re-registering it, printing "Caught exception: Already watching path" in front of every build error this project produced all week. It costs nothing to turn off -- measured twice each on a no-op compileDebugKotlin, 1.50s and 1.59s with, 1.46s and 1.46s without -- because that extraction invalidates the VFS anyway. A watcher holding stale state about a tree another tool rewrites causes the "clean and try again" class of problem rather than curing it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-31Shows get a priority, so the news is not stuck behind a back cataloguePeter Stone
Peter's framing: a few shows are news and stop being worth hearing within a day, a tier he keeps up with, and a long tail that is not topical at all. It matters because recognising one episode costs the server about five minutes, so a five-hour back-catalogue episode arriving the same morning as the news takes most of a cycle before the news is looked at. Three tiers and no more. A number would invite fiddling and would not mean anything; these are the three distinctions that were described. The tier rides beside the subscription list rather than inside it, so a server that knows nothing about tiers reads the list exactly as before and a phone that knows nothing keeps pushing what it always did. Only the shows placed somewhere other than the default are sent -- "ordinary" for sixty shows is the same information as nothing, in sixty times the space. It orders both the poll and the recognition queue. Recognition now sorts on three things in the order they matter: a topical show first, then an episode whose answer checks itself against the length gap, then newest. **A tier only exists for a subscribed feed**, which a test caught: the list and the tiers arrive in one push, and setting the tiers after the list put back a placement for a feed that same push had just removed. A resubscription months later would have inherited it. Unrecognised tiers are ignored at both ends and read as the default, which is the one that starves nothing -- the same rule the label store follows for a verdict it does not know. Schema 20, migrated and checked on a device. The control says what it does not do as well as what it does: nothing about playback changes, and a show placed anywhere still plays, downloads and skips identically. A setting that looks like it changes playback and does not is worse than no setting. The coverage gate caught the push body having no test, which was fair -- it is hand-built JSON going over the wire, where a missing brace is a library that silently stops being mirrored. Its exact output is now pinned, including a URL with a quote in it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-31A break says which detector found it, and the episode screen says soPeter Stone
Three detectors now cut audio out of episodes and the app said nothing about which. The listener judging a skip wants to know what judged it, and they are not equally sure of themselves: one compares waveforms across a batch of episodes, the other reads what was said and checks itself against the declared length. A break both found is marked as both, because agreement by entirely different means is the strongest thing either can offer. "Found by the audio and the words", under the numbers and quieter than them -- it explains them rather than competing with them. Schema 19. Existing rows default to the audio scan because that is where every break in the database came from; the words could not produce one until this week. "Unknown" would have been honest about the column and useless about the rows. A server that says nothing gets the same default, for the same reason. The coverage gate caught the new field's default never being exercised, which was fair: every call site passes it, and the case it exists for is an episode with nothing cut and nothing to account for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-31Score the detectors against what the listener heard, and fix two things it foundPeter Stone
Every other number in this project comes from the declared-versus-served length gap, which says how much advertising a copy carries and nothing about where. A detector could find the right total in the wrong places and score 100%. The position check I wrote earlier used the same signal that builds the candidates, so it was partly circular and structurally blind to the failure it most needed to see. The 19 labels are the only statement about *where* that no detector produced: each is Peter pressing a button at a moment in an episode. listen-score is read-only, takes the state file, and prints how the published breaks did. **Its first answer was wrong, and checking rather than reporting it caught that.** It said 2 of 18 advertisements were covered, which would have meant the detectors were far worse at position than at totals. They are not: breaks at 829-999 and 1360-1519 against labels at 853-1000 and 1489-1540 -- correct detections, scored as complete misses because the break stopped a second short of the label's end. Twelve of the nineteen labels have an end the store itself documents as a guess, "where a jump happened to land". Scoring against a guessed end is scoring against noise. A label's start is solid; its end counts only when measured. 5 of 18 after that. **A refetch master was blocking the better detector.** Tangle's "The Israeli elections" has a 229-second gap, a master from a second fetch that carried the same campaign, and therefore no subtraction candidates at all -- and findRecurring skipped it because it had a master. Treating "has a master" as "has a better answer" is wrong when the master is useless. It now skips only episodes that actually published something. **The one programme cut is the audio scan's, not the words'.** Its break at 696-708 sits exactly on the stretch Peter said was programme. Worth knowing before anything else gets blamed for it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-31Find what a show says every week, and publish it as the show's own audioPeter Stone
The text analogue of the audio scan, and it costs nothing: the server already holds transcripts for eight episodes of each show, mirrored within a minute of each other, carrying one campaign and eight different programmes. Text several of them share is not the programme. It reaches where subtraction cannot. Subtraction needs a second description of one episode -- a publisher's transcript or a second fetch -- and 18 of 337 mirrored episodes have one. This needs only the episodes already held. Measured against the length gap on the live library: Heavyweight 111%, Incels 105%, Ezra Klein 92%, Tangle 83%, against refetching's 21% on the same show. **It is published as the show's own audio, not as advertising, and that is a measured limit rather than caution.** What recurs is a show's sponsor reads and its furniture together -- its welcome, its break cue, its credits -- and I tried to separate them and could not. Scored against the 404 recurring stretches in episodes that also hold a publisher's master, which says which is which: ad copy markers 5% of the advertising, 4% of the furniture length mean 51s against 29s, overlapping throughout position ads 62 open / 176 mid / 41 close furniture 12 open / 61 mid / 52 close A first classifier built on ad copy agreed with the master 33% of the time and called 263 advertisements furniture. None of those signals separates the two. So the detector claims only what it measured: this is text the show says every week. That is what KindCue means, and it leaves the listener's setting in charge -- a show's own audio is skipped by preference, advertising unconditionally, and claiming the stronger of the two on a coin flip would be the worse mistake. Refused when more than 35% of an episode recurs, the same share the ad scan refuses at: a re-aired episode or a formulaic segment would otherwise be eaten whole. Guarded by share rather than by the length gap, because the episodes this is worth most on are the ones with no gap at all -- a show that bakes its reads into what it publishes declares the length it serves, so the gap is blind to every advertisement in it. Skipped entirely for an episode that already has measured advertising. The better answer must not be overwritten by the vaguer one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-31Write down the transcript pipeline, and that it is verified on a devicePeter Stone
CLAUDE.md still described the server as doing audio detection and nothing else. Six days of work later it recognises speech, subtracts transcripts, and publishes the result as skips, and none of that was written down anywhere a future session would look. The device verification is the part worth recording most. Heavyweight's "Jonathan + Death of an Artist", breaks at 0-1:45 and 13:32-15:16: downloaded from the server, both rows landed in episode_midrolls, twenty seconds of playing put the position at 2:03 -- which is only reachable by jumping the pre-roll -- and the post-roll skipped to the end, marked the episode played and reset the position. Server to phone to playback, on hardware, not inferred from the fact that each link has a passing test. Also written down: the measured 10.3x realtime, the four alignment mistakes that only measurement caught, why a declined transcript is kept rather than discarded, why refetching belongs to slow-drip shows, and the shared-text signal that is prototyped but not built. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-30Decide publishing every time, not only when the candidates movedPeter Stone
The re-derive pass skipped an episode whose advertising had not changed since last time, which is sensible for rewriting the file and wrong for everything else: what is *fit to act on* is a separate judgement from what was found, and it changes on its own -- it just did, when publishing was added. So the eighteen episodes that had been measured for days published nothing, because their candidates were already correct. Found by looking at the state file after deploying rather than by assuming the deploy had worked. The readout now says how many episodes publish how many breaks, every start, so "nothing changed" and "nothing is published" can no longer look alike. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-30Publish the advertising the words measured, and only thatPeter Stone
Subtraction has earned it. Eighteen episodes, 92% to 104% of what the declared-versus-served length gap implies, every pod found, edges pulled back off the programme, and the one class of mistake that mattered -- a break opening on the host's own sentence -- traced and fixed. **Only subtraction is published.** Reading the ad copy measured 0%, 0%, 15% and 29% on the same kind of episode, and on a show that bakes its reads in it found 2:13 where the lengths can see nothing at all. That stays a hint beside the words, dimmed on the transcript screen, and never becomes a seek. **The length gap is the guard as well as the measure.** It comes from the feed and the file rather than from any detector, so it is the one number able to contradict one. Nothing is published for an episode with no gap -- there would be nothing to check it against -- and a candidate set claiming half again as much as was inserted is refused with its reason, because it has found something other than advertising. They land in their own field rather than beside the audio scan's, which rewrites its list from scratch on every detection pass and would wipe them. The API serves the union, merging ranges that overlap or touch: playback seeks to a break's end, so two overlapping breaks would seek out of the first and straight into the second, and two touching ones would resume for no time at all before skipping again. Advertising wins where merged ranges disagree about what they are -- the phone skips advertising unconditionally and a show's own audio only by preference, and being wrong the other way means sitting through an advertisement the server had already found. Nothing about playback changes. They arrive in the field the phone has read since the on-device detector existed. **Refetching is now gated on age**, which is Peter's steer and what the numbers say. A second fetch made minutes after the first came back twenty seconds different and found 21% of the advertising; another came back the same length to the second and found none. Insertion varies the amount per request, not the campaign. So it is for shows whose episodes are still worth hearing four weeks later -- a slow-drip archive, a leisure show -- and not for daily news, which is pruned long before a campaign rotates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-30Tell the phone what reading the words concludedPeter Stone
Three numbers on the line that already says an episode has a transcript: how many stretches of advertising were found, how long they come to, and how. The method travels with them because subtraction and ad-copy reading are not close in quality -- 92-104% of the length gap against 0-29% -- and a figure on its own would invite reading the two alike. An episode that turned up nothing says nothing rather than zero, which is the same three-state care the rest of this takes. Still nothing published as a skip. This is so the evidence can be looked at from the app rather than from my logs, which is what the decision to publish should rest on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-30Log both copies' lengths when a second fetch landsPeter Stone
The first thing to look at when subtraction finds nothing in a refetched episode is whether the two fetches differed at all. Two copies that came back the same length probably carried the same campaign, and there is then nothing for subtraction to see -- which is a different problem from the alignment failing, and the readout should not make them look alike. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-30Fetch a second copy of an episode to subtract from the firstPeter Stone
Subtraction measures itself at 92-104% against the length gap and finds every pod, but it needs a publisher's transcript, and only 18 of 336 mirrored episodes have a usable one. So the best detector in the project covers about five per cent of what Peter listens to. This is Peter's idea for the other ninety-five. Advertising is inserted per request, which this project measured the hard way: the same Radiolab episode came back 7:29 of advertising on one fetch and 5:34 on another. So two fetches of an episode agree on the programme and differ on the advertising, and subtracting one from the other says where the advertising in the other one is. Inserted needs no change at all -- it already takes two cue lists and reports what the second has that the first does not. Confirmed before building the pass: a second fetch of Tangle's "The corruption is still the story" came back at 986 seconds against the mirrored copy's 1056, with both declaring 690. Seventy seconds of different advertising, from a request made minutes apart. What it cannot see is a campaign both copies happened to carry. That is the exact opposite blind spot to the audio scan, whose entire method is finding what a batch of episodes shares -- so between them the two cover each other, which is worth more than either being better. The second copy's audio is deleted the moment it is recognised, and it is fetched into its own directory under its own id, so nothing can mistake it for the bytes the phone plays even for the moment it exists. Only episodes whose served length exceeds what the feed declares are fetched at all: without a gap nothing was inserted, and the second copy would be the same recording. Bounded to twenty minutes a cycle, abandoned after three failures in a row with the attempts given back, and ordered newest first -- the same shape as recognition, for the same reasons. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-29Judge a short line on a shorter run, and catch a bare music notePeter Stone
Printing the first line of all 65 pods in the corpus found the one failure that actually matters -- a break opening on the programme -- in about eight of them. Three were identical: "I'm Jack Resider.", which is the Darknet Diaries host saying his own name, misheard. Rhysider is not a word whisper knows. Four words out of four means one misheard word anywhere in a short line hides the whole line from the master, so it is never given back. Three of "i m jack resider" still finds "i m jack" where the publisher wrote "I'm Jack Rhysider". Short lines are now judged on three; long ones are unchanged, where a full run is affordable and a looser one would match by accident. The annotation pattern also missed a bare "♪", which is a music note without brackets round it. Measured across the eighteen episodes holding a master: before 99% - 106%, mean 101% after 92% - 104%, mean 100% The three that fell furthest are the three Darknet Diaries episodes, and they fell because the host's sentence is no longer inside the break. Falling below the length gap is the right answer there: the gap counts advertising, and his name was never advertising. Eight suspect pod openings are down to four, and all four are now either a one-word line too short to judge at all ("Yay!", "- Pessimism.") or a line that is mostly advertisement with a second of handoff in front of it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-29A music cue at the edge of a break is not part of the breakPeter Stone
whisper writes "[music]", "(upbeat music)", "(audience laughing)" -- 312 of the 56,306 recognised lines in the library. They are not speech, so no publisher's written transcript contains them, so they can never anchor, so they sit at the front of a break and widen it. Earlier I said these could not be told from an advertisement and left them alone. That conflated two different things. A title card -- "Today's episode, Tamara." -- genuinely cannot be told apart, and is still left alone. A non-speech note says what it is by its shape. Measured across the eighteen episodes holding a master: before 99% - 110%, mean 103% after 99% - 106%, mean 101% #69 Tamara 110% -> 103% #70 Cayden 109% -> 106% Regrets 108% -> 104% Gobi 104% -> 100% Fourteen of the eighteen now sit within one point of the length gap. Nothing regressed. They are trimmed from break edges but still stored and still shown: a reader following along is better off seeing the laughter than a hole where it was. This is a judgement about where an advertisement starts, not about what was said. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-29Decide where a break is line by line, not by how far the master driftedPeter Stone
Three episodes were missing a whole advertisement, which was the largest error left in subtraction. Diagnosed rather than guessed at: Incels "From Victim to Violence" has a three-minute pre-roll in which the served copy runs 463 words against the master's 84, and it was refused because the master advanced more than the ten words of drift the rule allowed. That guard was there for a real reason -- a stretch where both texts advanced is one where you cannot say which part of it is the advertisement -- but it answered the question at the wrong resolution. The gap as a whole need not be all advertising; its individual lines are each either in the master or not, and that is answerable. So the drift guard is gone and every gap is partitioned line by line against the master instead, which is the same machinery the edge trim already used. The trim is no longer a separate step a caller has to remember, which is just as well: a caller who forgot it would publish a skip over the last sentence of the programme. Measured across all eighteen episodes holding a master: before 73% - 110%, mean 99%, three episodes missing a pod after 99% - 110%, mean 103%, every episode finding every pod #67 Jenna 81% -> 106% 3 pods -> 4 Behind the Scenes 73% -> 101% 3 pods -> 4 From Victim to Violence 78% -> 99% 3 pods -> 4 Nothing got worse. The fifteen episodes that were already right are unchanged to the percentage point. The fixtures spoke at 360 words a minute, which hid the fact that the word floor and the duration floor disagree about what counts as an advertisement. They now run at about 180, which is roughly how fast people talk, and the two floors agree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-29Re-derive stored advertising when the rule improvesPeter Stone
Recognition is the expensive part and its output does not change. Which stretches of it are advertising is a judgement that does -- and when subtraction learned to pull its edges off the programme, the eighteen episodes already transcribed kept the old boundaries. Re-recognising them to pick up a better answer would cost hours of CPU for text already sitting on disk, so this runs over the stored transcripts at startup instead: eighteen alignments, a few milliseconds each, writing only where the answer actually moved. The next improvement costs nothing to apply either. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-29Pull a break's edges back off the programmePeter Stone
Subtraction answers "not in the master", which is a superset of "advertising". Across the eighteen episodes holding a master the candidates come to a mean of exactly 100% of what the length gap implies, and individually overshoot by up to 10%. Reading their edges showed two causes. The first is the programme leaking in. Darknet Diaries' "NSL" opened a break on "I'm Jack Resider." and closed one on "So Nick and ACLU, we're going to challenge"; Incels opened two on "let's stop here for a...". Those lines failed to anchor only because recognition dented a word near them, and skipping them is the harm this whole thing exists to prevent. Their words are in the master, so they can be found there and given back. The second is whisper's own "[MUSIC PLAYING]" and "(upbeat music)" and a title card. Those are genuinely absent from a publisher's written transcript, so nothing here can tell them from an advertisement. Left alone: they sit against the break anyway, and a rule invented to catch them would be a guess. **Measured in both directions**, which is the lesson MinRunSamples taught: Worship at the Feet of a Coward 108% -> 100% the 764 Network 104% -> 100% The Manosphere 104% -> 101% I hit you because I love you 104% -> 101% Gobi 107% -> 104% From Victim to Violence 86% -> 78% Behind the Scenes 75% -> 73% The three that got worse were already the worst, and not because of this: each finds three pods where its show's others find four, so it is missing a whole break. The trim removes edge text from the pods they did find, before and after alike. Mean 100% -> 98%, and the top of the range is where it should be. Four words, five and six all measured. Five and six are identical and four differs by one point on one episode, which is noise at this sample size. Four is kept because it is the only one that still recognises a short line with a mistake in the middle -- an eight-word line with a dented fifth word has no clean five-word run, and "So Nick and ACLU, we're going to challenge" is eight words. Trimming more also errs towards smaller candidates, which is the safe direction here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-29Do not claim to know why subtraction ran overPeter Stone
Nine episodes subtracted against their publishers' masters land between 82% and 110% of what the declared-versus-served gap implies, and all but one are over. The readout said the excess was advertising baked into what the publisher serves, which the gap cannot see. That was a guess, and on inspection it is wrong at least some of the time. Heavyweight "#70 Cayden" is over by 46 seconds across four candidates. Its first candidate opens on `Today's episode, "Caiton."` and `[MUSIC]` -- a title card and a music sting, neither of which is advertising and neither of which appears in a publisher's written transcript either. Subtraction answers "not in the master", which is a superset of "advertising": whisper's own [MUSIC] and `>>` markers qualify too. So the readout now states the overrun and names both possible causes instead of picking one. Tightening the edges is a separate piece of work and needs to be measured in both directions -- the same lesson MinRunSamples taught, where improving one show doubled a false positive on another. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-29Recognise the episodes whose answer checks itself firstPeter Stone
Recognition was working strictly newest-first, so the seventeen episodes holding the publisher's ad-free master sat behind 259 that do not. Those seventeen are the only ones where the result validates itself: subtracting the master found 4:20 of the 4:15 the lengths imply on Darknet Diaries and 3:32 of 3:29 on Heavyweight. Everything else falls back to reading the ad copy, which across the first nineteen real episodes found 0%, 0%, 15% and 29% of what the lengths say is there -- and on a show that bakes its sponsor reads in, found 2:13 where the lengths can see nothing at all. Useful as a hint, not as a measurement. So the budget goes where the result can be trusted first, and stays newest-first within each group. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-29An episode started from the car resumes where it was leftPeter Stone
Reported as "the play button, everywhere, should resume from the listened position, not start over". Every in-app surface was already correct, checked on a device before anything was changed: the library row resumed a seeded 5:00 and played on from 5:07, the episode screen showed "7m in" and labelled its button Resume, and both go through PlaybackAdvancer, which is where this app decides where an episode starts. So the bug was not in the resume rule. Android Auto does not go through any of that. A Bluetooth button, the system's resumption and the car all reach playback through the session callback, and that path had only onAddMediaItems -- which turns a browse id into something playable and says nothing about where in it to start. The player therefore began at zero. It survived because it is wrong only where nobody developing the app presses play. onSetMediaItems now supplies the position, from PlaybackAdvancer, so the car and the phone cannot drift apart on where an episode starts. **The position is looked up from the items as they arrived, not the resolved ones.** Resolution replaces a browse id with a playable item carrying the audio URI, and the browse id does not survive it -- which is the whole reason onGetItem exists. The first version read the resolved item, got null, and resumed at zero: the same bug wearing a different hat. Found by logging it rather than by reasoning about it, after the test kept failing with the fix in place. Pinned by MediaBrowseTest over the real session protocol, because the entire defect lives in a boundary a direct call would skip. Watched failing with only the production change stashed: expected 420000, was 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-29Say what the length gap can and cannot seePeter Stone
The declared-versus-served gap bounds only the advertising a CDN inserted into this copy, which is not all the advertising in it. A show that bakes its sponsor reads into the file it publishes has no gap at all and can still be a third adverts: The 404 Media Podcast declares within 11 seconds of what it serves and carries Mack Weldon, Incogni and Warby Parker reads in the middle of it. So finding more than the gap implies is not over-reporting. The first reading of a "728%" line in the live log was that the detector had gone wrong, and it had not -- every one of those candidates is a real host-read advertisement. The readout now says which case it is instead of printing a percentage that invites the wrong conclusion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-29Find the advertising by subtracting the publisher's masterPeter Stone
Peter's idea, and it is the best detector in this project because it is a measurement rather than a guess. A publisher's transcript describes the ad-free master. Recognition over the mirrored file describes the copy the phone will play. The copy is the master with advertising stitched into it, so the text present in the served copy and absent from the master *is* the advertising, with its own boundaries. No phrase list to be wrong about, and no show whose hosts happen to talk like an advert. Measured on Darknet Diaries' "Conti" before it was wired in. The feed declares 59:17, the file served runs 63:33, so 4:15 was inserted. Subtraction found **4:20 in three pods -- 102%** against a figure no detector produced. Reading the ad copy on the same words found 0:16, or 6%. Every pod starts on the first word of an ad read and ends on the last: a NetSuite pre-roll, a 2:20 midroll, and a Doppel read at 32:08. Nothing of the programme is clipped. The alignment has to survive recognition being approximate -- whisper writes "Bombus" for "Bombas" and spells numbers differently -- so it anchors on runs of five words occurring exactly once in each text. A window containing a mistake simply fails to anchor and its neighbours carry the alignment instead. Uniqueness is what makes an anchor safe: a phrase appearing twice in the master cannot say which occurrence it matches, and a wrong anchor drags the alignment across a break. Inside an advertisement nothing matches at all, which is exactly what makes a pod show up as a gap. An anchor names the *start* of a matching run, so the content between two anchors begins after the first run ends. Getting that wrong put every break one anchor-length early, which on a real episode is a skip over the last sentence of the programme. The tests pinned it. **This is why a declined transcript is now kept.** Declined means "this copy carries inserted advertising the master does not describe" -- precisely the episode subtraction works on. One declined for describing a different recording is still thrown away, since it is no use for anything, and Fits now carries a reason rather than making callers match on its wording. The nineteen episodes declined before this existed fetch their masters once on the next poll. Nothing is published as a skip yet. The candidates are stored with the words and dimmed on the transcript screen, and every episode now logs what was found against what the lengths imply. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-29Recognise the words in episodes no publisher transcript can describePeter Stone
whisper.cpp, installed on tungsten and shelled out to per episode. The other 304 of 342 mirrored episodes -- the ones whose publisher's transcript was declined because this copy carries inserted advertising the master does not describe, which is both why it is unusable and why having words would be worth most. **Measured, and an order of magnitude cheaper than I told Peter.** base.en on six threads does 10.3x realtime on this box: a 63-minute Darknet Diaries episode took 6m11s at 793MB peak. The earlier "roughly realtime, days of backfill" was wrong. The real backlog is 298 episodes, 266 hours of audio, about 26 hours of CPU -- three days at 45 minutes per two-hour cycle, which is the default budget. Bounded per cycle rather than exhaustive, and only on the timer: a wakeup is the phone pushing a subscription list and wanting that show polled now, and following it with three quarters of an hour of recognition would make a new subscription feel broken. Newest first, because an episode about to be played is worth having words for before one from the back of a catalogue. **The first real run found two bugs the tests could not have.** The working directory was never created, so ffmpeg could not write and every episode failed -- and each failure was recorded as "do not try this one again". A misconfigured deploy would have marked the whole backlog attempted in seconds and nothing would ever have been transcribed, which is precisely the shape of the midroll scan marking episodes checked forever after an OutOfMemoryError nobody saw. transcribe.Breaker now abandons a pass after three consecutive failures and gives those attempts back, since a run of failures is the engine rather than the episodes -- while one bad file among good ones is still marked, because the same bytes will fail the same way next time. NeedsRecognising is deliberately not the negation of TranscriptExamined. A declined transcript means examined, no words, and exactly the episode recognition exists for. **First validation against ground truth the detector did not produce.** Up First "Democrats Lead In NPR Poll": the feed declares 788s, the file served 890s, so 102s of advertising was inserted -- and reading the recognised words found 100s of it, in two pods, every line unmistakable ad copy. The sibling episode found 21s of 131s: a whole pod at 54-130s was missed because its copy carries only one kind of marker. Reported rather than tuned around, on two episodes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-28Read the advertising out of an episode's wordsPeter Stone
A second ad detector, and it finds a class the first structurally cannot. The audio scan compares episodes mirrored within a minute of each other, so what it finds is advertising a CDN stitched into all of them -- validated against the declared-versus-served length gap. A show that inserts nothing has no gap, which by that measure means it carries no advertising, and the host-read sponsor block in the middle of it is invisible. Every published transcript the server can use belongs to exactly such a show, because having no inserted advertising is the condition for using one. The two detectors do not merely fail differently; they look at disjoint sets of episodes. **Density, not phrases.** One marker is a mention. Measured against the 38 transcripts harvested from the live library: a single "brought to you by" is a one-second tag inside play-by-play and skipping it would cut the game, and a guest describing how a sponsor came forward for his yacht event says "sponsor" four times while being the programme -- the same trap the toc=false chapter rule already documents. A real read stacks different *kinds* of marker inside a few seconds: who it is for, where to go, what the offer is, what the terms are. Two kinds and fifteen seconds, the same ad unit the length-gap rule uses. Every threshold comes from a measurement rather than a guess. The 25-second bridge is the 17.7-second gap between "presented by Delta" and "12% off" in the Seahawks Rewind read, with the product description in between. "Slash" needs a domain in front of it because the corpus contains "a children's movie slash book for adults". Across all 38 live transcripts it reports **one** candidate: that Delta block, 41:07 to 41:33, with no programme clipped at either end. That is low recall by design -- these are the shows with the least advertising, because they are the only ones whose transcripts can be used at all. The shows with real ad breaks need speech recognition, which is not built. **Nothing is published as a skip.** The candidates are stored with the words and the lines are dimmed on the transcript screen, so the reader can see what the server thinks is an advertisement and disagree with it. Dimmed rather than hidden, because a wrong call has to stay visible. Nothing in the playback path reads them -- same posture as the signature library's shadow run, and the same reason: a detector acting on its own unchecked first guesses makes skips into the middle of the programme, which is worse than no skips. Schema 18 adds the mark to the cue, defaulting to not: a line nobody judged is not an advertisement. Migrated and checked on a device. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-28The app reads an episode's words, and follows them while they playPeter Stone
The ⋮ menu gains Transcript, which opens the episode's lines with the one being spoken in bold and the list following the playhead. Tapping a line seeks to it, which is the thing anybody actually wants a transcript for -- finding the bit you half-heard. It is also why the server refuses a publisher's transcript for an ad-inserted copy. Tap a line in a transcript of the ad-free master and the audio starts minutes from where the words are. The words arrive with the audio rather than when the screen opens. The point of downloading is that the episode works with no network, and a transcript fetched on opening is blank on a plane or in a car park, which is where this app is used. Tens of kilobytes beside tens of megabytes. One row per cue, not a JSON column. Android reads every Room row through a CursorWindow that refuses a row over 2MB *on the way out* -- the insert succeeds and the next read throws -- which is exactly how the oversized pendingClipPcm crashed the app on every show's second fingerprinted episode. A three-hour episode is thousands of cues, so one column holding them all is that bug with a different name. Both tables cascade from the episode. Schema 17, migrated and checked on a device, including that deleting an episode takes its words. Three states again, kept apart: words to read, a recorded reason there are none -- the server looked at the publisher's transcript and would not use it for this copy -- and nobody having looked. The menu offers the entry for the first two and the screen says something different for each. TranscriptSyncing holds the highlighting, apart from the screen and tested, because the way this goes wrong is quiet: a highlight on the line before or after the one being spoken looks right at a glance and is useless to read along with. It holds the last line through a gap rather than flicking off during silence or music, and highlights nothing before the first line, since an episode opening on theme music has not said anything yet. Verified end to end against a real feed before committing, on a throwaway data directory: Wards Way Podcast mirrored, both episodes' transcripts taken, and the API served 748 cues whose last one ends 466ms before the measured file does. Transcripts live on their own view model rather than two more functions on ListenViewModel, which was already at the size the linter allows -- same call as splitting Screens.kt, and they have a different lifetime anyway. The coverage gate caught the replace transaction having no direct test, which was fair: its clear-then-insert ordering is the thing that would leave a reader looking at an empty list under a header counting lines. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-28safefetch: an allowlist, and no proxy to go round itPeter Stone
Two follow-up findings on the same file, both real. **A proxy would have made the whole check pointless.** http.DefaultTransport carries ProxyFromEnvironment, so with HTTP_PROXY set the connection goes to the proxy and the proxy connects to whatever the URL named -- the address being checked would be the proxy's, and the one that matters would never be looked at. The safe client now sets no proxy, and a test asserts it. **"Not RFC 1918" is not the same as "public".** The check is now an allowlist: an ordinary global unicast address that is not in an IANA special-purpose range. The ranges that were being let through include 100.64.0.0/10, which is carrier-grade NAT and routes to a real host on a real network, and 64:ff9b::/96 and 2002::/16, which are NAT64 and 6to4 and carry an IPv4 address inside them -- 127.0.0.1 included. None is private by Go's definition and none is somewhere a podcast is served from. Checked against the live library before committing: 130 real URLs -- all 44 feeds and two enclosures from every show -- resolve to addresses this permits, so nothing Peter subscribes to stops mirroring. That check is kept as a test which skips where the state file is not present, because the failure mode of being too strict is a feed that silently stops updating. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-28Every outbound fetch may only reach a public addressPeter Stone
A security review flagged the transcript download as an SSRF: the URL comes from a <podcast:transcript> tag, so a publisher chooses it. It was the third place with that shape, not the first -- the feed fetch takes a URL the phone sent, and the enclosure download takes one from inside the feed, and both had been doing it for longer. Fixing the flagged instance alone would have fixed nothing. The exposure is specific rather than theoretical. listend listens on 127.0.0.1:38090 behind Apache on a host running other services, so a feed naming a loopback, private or link-local address -- 169.254.169.254 among them -- would have the server fetch something only it can reach. In the transcript case the result would then be stored and handed to the phone as an episode's words, which turns an internal reachability problem into a readable one. The check runs when the connection is dialled, not when the URL is read, so it also covers a redirect into an internal address and a hostname that resolves to one. The address connected to is the address that was checked. internal/safefetch now has a structural test that walks this server's own source and refuses any production file that builds its own HTTP client or uses the shared default. It was watched failing against a deliberately added violation before being kept. Tests still inject a plain client through the seam each type already had, because a httptest server is on loopback by definition -- which is exactly what production is now refusing. Checked against the live subscription list before committing: all 44 feeds use https and none resolves to a private, loopback or link-local address, so nothing Peter subscribes to is affected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-28Take publishers' transcripts, where they describe the copy we mirroredPeter Stone
Eleven of the 44 subscribed feeds carry <podcast:transcript>, in SRT or WebVTT. Those are the publisher's exact words with the times they were said, which is free and better than anything recognition would produce. **They are only usable when the show does not insert advertising**, and that turns out to be exact rather than approximate. A published transcript describes the publisher's master. Measured against the live library on 2026-10-28: Darknet Diaries "Conti" declared 3557s served 3813s last cue 3544s Wards Way "A Family Bar" declared 1216s served 1217s last cue 1213s LOW "Afterword" declared 610s served 610s last cue 603s LOW "Part 5: Bane" declared 2822s served 2823s last cue 2807s The transcript tracks the *declared* length every time. So it aligns precisely when served and declared agree, and is minutes out of step precisely when they do not -- which is the case where a transcript would be worth most. Reading along with one of those would put every line in the wrong place, and a break placed from it would seek into the programme. transcript.Fits makes that call from the two lengths rather than from the words, because the lengths are independent of the transcript: the feed declares the ad-free programme and the file measures what was served, so their difference is this copy's advertising. It is the same figure and the same fifteen-second unit the ad scan already validates itself against. A refusal is recorded, not retried. It is a judgement about this copy, so re-fetching every poll would be work done for ever to reach the same answer -- and the refusal is the record of which episodes speech recognition will have to do. Refused, taken and never-looked-at are three states, kept apart here and in the API, because conflating the first and third is the readout mistake this project has already made twice. Run against the live library before committing: 342 mirrored episodes, 57 offered a timestamped transcript, 38 taken, 19 declined -- and every decline is a show with dynamic insertion (Heavyweight 10:03, Incels 11:54, Darknet Diaries 4:23). Transcripts live one file per episode beside the audio, not in state.json: that file is the only record of where mirrored audio is and is rewritten whole on every label and every poll, and Darknet Diaries' "Conti" alone is 1784 cues. They are pruned with the audio they describe. Both spellings of the Podcasting 2.0 namespace are read. Every feed here declares https:// today, but the specification was published under http:// and Go matches a namespace by exact string -- so the wrong one would mean episodes silently never getting words, with nothing failing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-28The library row carries the show's artwork, not a dotPeter Stone
The dot said only whether anything was waiting. The count beside the title says that and says how much, so the dot was the row's least informative element sitting in its most prominent position. The artwork spans both lines, which is what makes it read as belonging to the show rather than to the episode named underneath it, and it carries the caught-up dimming with the rest of the row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-28Library rows offer the newest waiting episode, and the car sorts like the phonePeter Stone
Three things Peter reported about the podcast list. **A slow-drip show sorted on episodes it was hiding.** The library's count and its sort key were computed from every unplayed, unqueued episode, released or not, so New Yorker Fiction ranked near the top on a brand-new episode drip had not handed out yet -- a show with nothing visible sitting above shows with something to hear. Both halves now apply the same released-set the show screen shows. The count was wrong in the same way and for the same reason, so it moves with it. **The row now names the one episode you would actually play**, under the show's name and in a quieter style, with the same Play, Queue and Download controls the episode list offers. The show stays the focus: this is a list of shows, and an episode offering different actions depending on which screen you found it on would be two features wearing one name. `observeNewestWaiting` sits beside `observeAll` because the two must agree on what "waiting" means -- a row that offered an episode the count excludes would be offering something the show claims not to have. **Android Auto lists shows in the library's order now.** It was sorted, but alphabetically, which is a different order from the phone's and so reads as no order at all. `getBrowsable` is gone rather than reordered: the callback reads `observeAll` directly, so there is one definition of what the library contains and what order it is in, and the car cannot drift from the phone again. Alphabetical was also wrong on its own merits -- the car hides played episodes, so a show you are caught up on browses as an empty list, and burying the ones with something to hear among them is exactly what a driver can do nothing about at a traffic light. Pinned by `MediaBrowseTest.theCarListsShowsNewestWaitingFirst`, which walks the real session protocol and failed on alphabetical order before the change, five new drip cases and five newest-waiting cases in `LibraryOrderTest` against a real database, and `LibraryRowTest`, which drives the row as a composable because a row offering the wrong episode's controls looks exactly like one offering the right episode's. `LibraryScreen.kt` split rather than relaxing detekt again, same call as `Screens.kt`: the row and its parts are now `SubscriptionRow.kt`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-27Plan: the labels are wired end to end, and when they are not appliedPeter Stone
Verified on the live server: a mark vouching for a real detected break reaches the signature that break produced and sets confirmed on it. The probe and the flag it set were removed afterwards; that corpus holds the listener's statements, not test data. Also records the property that cost two runs to notice -- a label is only actionable while its episode is still mirrored. They outlive the audio deliberately, so the server holds plenty naming episodes long rotated out, and those are simply not applied. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-27Use the labels: seed signatures from them, and take them backPeter Stone
The labels have been arriving and sitting there. Now the shadow run acts on all three kinds. A report is advertising pointed at where detection found nothing, so it becomes a signature in its own right -- that being the whole gap the library exists to close, since the scan accounts for about 38% of what the feeds imply is there. A mark saying a break is right adds no audio, because the break is harvested anyway. What it adds is that a person vouched for it. A mark saying a break is wrong does two things. It stops that audio being harvested, and it takes back any signature already held that matches it. That second half is the one that matters: a signature built from programme would cut the programme in every episode carrying it, until somebody noticed. So his "no" removes it outright rather than lowering a score. Being pointed at outranks partners and occasions both. Those are ways of inferring what a stretch of audio is; he was there. A confirmed signature is trusted at once and stays confirmed -- he does not un-hear it next week. A report whose end was never corrected is trimmed to its first fifteen seconds. An uncorrected end is wherever the 25-second jump landed rather than where the advertising stopped, so its tail may be programme -- and one ad unit is already far more audio than a landmark fingerprint needs to be distinctive. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-27Start testing the Compose screensPeter Stone
The dependencies for this were already in the build -- ui-test-junit4 on androidTest, ui-test-manifest on debug -- and had never been used. So the spike was one test, and it passed first run. Eighteen now. What they cover is the data-driven composables, chosen because a regression in them looks completely normal: a readout that quietly stops printing one of its three numbers, a control that stops reporting whether it will play or pause, a bar that stops naming what is playing while a screen shows something else. The episode transport: the ad button is offered only while something is playing, because with nothing playing there is no advertising to be in the middle of and an enabled button would record a claim about a position nobody is at. The primary control says what pressing it will do. The skip buttons move by the amount they advertise. The skip readout: all three numbers on one line, the status when there is nothing to cut -- "nothing found" and "not checked yet" being different answers is the mistake the old readout made -- and a legend that names only what is actually on the track. The now-playing bar: names the episode and the show, opens what is playing when tapped, and reports play against pause. Screens that need view models are still verified by eye. This is the half that did not need them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B