| Age | Commit message (Collapse) | Author |
|
Two of Peter's, both on surfaces that already had the pieces.
Holding a show in the library plays what it is offering, the same gesture the
episode list has carried since the row buttons changed. Playing a show's newest
episode meant opening the show first, which is two taps for the thing you want
most often. A show offering nothing does nothing when held, rather than
something arbitrary -- tapping still opens it either way.
The transport's second row reads sleep, speed, ad. It was the reverse, which
was my ordering and not a considered one. Sleep is set deliberately before
settling down so it takes the near edge; the ad button is hit mid-listen when
something slips through, so it sits furthest out where a stray press costs the
least.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Apache logs the request line, so a token in the query string was a copy of the
secret in every line of a file logrotate keeps fourteen of. That is why the
vhosts log /audio/ without query strings at all; this removes the reason they
had to.
Safe now because both halves of the phone send the header: the downloader
always did, and the player does since it got a ResolvingDataSource. Verified
against this server before changing it -- the player reaches READY on
listen.terst.org audio with a header and no token in the URL.
Query tokens are still *accepted*. Withdrawing that as well would lock out an
older build with no way back in, and accepting one costs nothing now that
nothing emits it.
The transcript URL made this move already, for the same reason. This was the
last one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
The last thing standing between the token and the query string it should not
be in. The downloader moved to an Authorization header a while back; the player
never did, and the player is how the car plays anything not already on disk.
Withdrawing the query parameter without this would have broken streaming in the
car silently -- a 401 arriving as "playback failed".
ExoPlayer resolves a URL on its own loading thread with no coroutine to read
DataStore in, so ServerAudioAuth caches the configuration and the session
callback refreshes it before handing over any stream URL. Nothing is cached per
URL: the same player fetches publisher audio, and the whole point of
ServerBearer is that those get nothing. Attached through a ResolvingDataSource
rather than as a default request property for the same reason -- a default
would send Peter's server credentials to every podcast CDN he subscribes to.
Before the first refresh it sends nothing, which is the safe direction: a
stream that should have carried the token fails visibly, where one that should
not have carried it would have leaked it.
Verified against the live server, not a fake: the player reaches READY on
listen.terst.org audio with a header and no token in the URL, and the same URL
without credentials is still refused. The token is supplied as an
instrumentation argument and is not in the repository -- a test file carrying
it would be the same mistake in a different file -- so the test skips unless
one is passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Two things, both about finding something rather than playing it.
Android Auto lists category folders under Podcasts instead of forty-five shows
in a row -- a car screen holds six to eight, so anything not near the top was
several flicks away. The categories already existed and already had an order,
so this costs no new data. With only one category present the folders would
separate nothing, so the shows are listed directly and the level disappears.
A show that has not published in a year says so above its drip control:
"No new episodes in 3 years". That is the whole of the special treatment a
finished show gets -- no flag, no automatic behaviour, no guessing that a show
is over. A run of silence is a fact and "finished" is not. A year, because
shorter fires on everything merely between seasons, and a line that appears on
everything says nothing. It costs no extra query: the sheet already reads every
episode for the detection readout.
The browse id format moved out of LibraryTree into BrowseIds, because that
object went past its function limit and the split is along a real line. The
format is the seam that has actually broken -- an id that does not survive the
round trip to the browser is what made Up Next show something other than the
queue -- and it could not be tested off a device before, because LibraryTree
builds Media3 items. It has six tests now, including that an episode id and a
show id carrying the same raw string are never confused for one another.
Verified over the real session protocol on the emulator: the folders appear,
the News folder holds the news show and not the fiction one. 8 browse tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Four things Peter asked for, all on the episode screen, which is the now
playing screen -- there is no separate player.
The artwork is 72% of the width rather than a 96dp thumbnail, so it reads as
the artwork of what is playing instead of a list icon. Short of full width on
purpose: the title and the transport still have to be on the first screen.
The three controls you press without looking -- back, the primary, forward --
have a row to themselves at a size worth aiming at. Five controls on one line
made all five small. The ad button, the speed and the sleep timer share a
quieter row below.
The sleep timer is on the screen now instead of three taps into the overflow
menu, which is the wrong depth for something reached for in the dark. The chip
carries what the timer is waiting for -- it reads 14:55 while a countdown runs
and "End of episode" in the other mode -- so nothing has to be opened to find
out. It opens the same dialog the menu does; one set of durations, one
explanation of the fade, two ways in.
The notes are shown in full and never behind a tap. The fold existed so long
notes could not push the transport off the screen, and the transport moved
above them when the two screens merged -- so all it still did was hide the half
of the screen it was there to protect. DescriptionPreview and
ExpandableDescription go with it.
The wording moved to SleepLabel, which is tested: it was inline in the menu and
again in the dialog, and the chip would have been a third copy.
Verified on the emulator rather than from the diff: the screenshot shows all
four, the chip opened the dialog, and arming 15m turned the chip into a running
countdown.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Both describe behaviour that was deliberately changed and neither was updated
with it, because the instrumented suite is not in the commit gate: it needs a
device, so it only runs when someone asks for it. 108 tests, two of them
asserting the opposite of what the app now does.
The library row asserted a Download button. The row's three became play next,
queue and mark played a commit ago -- play is a long press on the line and a
queued episode downloads itself -- so the test named a control that no longer
exists. Mark played now has a test of its own; it was the one the change was
for and the only one of the three with none.
The car's resume test seeded an episode with no file, from when playback would
fall back to the publisher's copy. That fallback was removed on purpose, so the
car now starts a fetch and plays nothing this time round -- the test was
asserting a resume against an empty playlist and failing on the position being
zero, which is the right answer to the wrong question. Confirmed from the
device's own log, "nothing to play for resume-ep from outside the app", rather
than inferred. It seeds a downloaded episode now, so the defect it exists for
-- the position being dropped at the session boundary -- is what it measures.
Whole instrumented suite: 109 tests, no failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Browsing by show is the wrong shape for driving: it asks you to remember what
you were doing and then find it, which was behind "discovery sucks" and
"scrolling the lists in the car is bad experience". Three lists sit above the
shows now and answer the question actually being asked at a traffic light --
what was I in the middle of, what fits this drive, what is new.
All three come from data the app already keeps. No new column, no migration.
Short cuts off at 20 minutes, which is Peter's call rather than a guess: it
catches the daily news and the shorter curiosity episodes and leaves out
conversation and deep dives. It reads COALESCE(actualDurationMs, durationMs)
-- playbackDurationMs in SQL -- because a feed declaring 22:05 for a file that
runs 28:18 would otherwise put a half-hour episode in a twenty-minute list.
An episode of unknown length is left out; unknown is not short.
Continue is the one list that does not exclude queued episodes, because the
episode you are in the middle of is normally the head of the queue and
excluding it would empty the list in the ordinary case. Short and New exclude
anything started, so finishing something is never buried under a new arrival.
A list holding nothing is left off the root entirely rather than opened and
found empty -- the same dead end the phone's library dims a show for.
The queries are their own narrow CarListDao rather than three more methods on
EpisodeDao, which detekt was right to call already the widest in the app: they
are read-only, they all cross every show rather than belonging to one, and
they exist for a single surface. Not on UiGraph, since no view model wants them.
Verified on the emulator: 9 CarListsDbTest cases against the real tables, and
MediaBrowseTest walks the real session protocol to the Continue node and finds
the part-heard episode in it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Tier said "how important" and the shows needed "what kind". Seven ordered
categories replace it: news, analysis, curiosity, sailing, true stories,
fiction, deep dives. The order is still the server's processing priority, so
one field carries both axes -- which is the idiosyncrasy docs/show-kinds.md
is written to watch, with four signs that a better abstraction has arrived.
All 42 known feeds are refiled by migration 20 -> 21, which rebuilds the
subscriptions table rather than renaming the column: RENAME COLUMN keeps the
old 'ordinary' DEFAULT and fails schema validation, and DROP COLUMN needs
SQLite 3.35 against minSdk 29's 3.28. The wire value for true stories is
"truestory", not "true", because SQLite reads an unquoted true in a DEFAULT
as the boolean and stores 1.
Schema 21 is a new exported JSON; migrate20To21RenamesTierAndRefilesTheKnownShows
covers it, and all 22 device migration tests pass.
The coverage exclusion for the settings composable follows the file's rename;
left pointing at TierSettingKt it read as a 0.6% drop. The remaining 0.08% is
the deletion of the covered tier-seeding code, taken as an exception.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
**Android Auto was broken by my own fix, and this is the third attempt at the same decision.**
It started by falling back to the publisher's enclosure, which plays a differently-ad-inserted
copy so the measured breaks cut into the programme. Fixing that made the browse callback
*download* first -- tens of megabytes inside the future Auto is waiting on -- so the car reported
"failed to load" and the episode played later only because the download had finished by itself.
That is exactly what Peter saw.
The distinction missed twice: streaming is not the problem, streaming *the publisher's copy* is.
The server's copy is the audio the offsets were measured against, so playing it is as correct as
playing the file and it starts at once. The car asks the server (one small request), plays the
download if there is one, the server's copy otherwise, and nothing at all if neither -- never the
publisher. The fetch still happens, on its own coroutine, so the next attempt has the file.
OutboundRequestsTest said "no remote media item", which was the wrong rule and is why it had to
be relaxed to let this through. It now says what was always meant: nothing in the playback
package may touch `mediaUrl`, the address the *publisher* nominated. Watched failing.
**The car's Up Next was not the queue, and one episode looped.** Both are the same bug, and not
in the browse tree -- that listed the real queue all along. Tapping an episode set the *player's*
playlist to that one item, so the car's own Up Next showed one entry and a single-item playlist
repeats instead of moving on. The playlist is now the queue, starting at the episode tapped. An
episode browsed from a show is not in the queue and still plays alone, because dragging the queue
in behind it would quietly change what plays next.
ReachabilityTest then found `startFetchingIfNeeded` had no callers left -- it existed only for the
car path this replaces -- so it is gone, with the two tests that were really about `fetchAndStart`
rewritten against it rather than deleted with it.
**The lists, as asked:**
- The library row offered play, queue, download. It now offers play next, queue, mark played.
Play is a long press and download happens by itself for anything queued; what was missing was a
way to say "not now" without opening the show.
- Episode rows had five icons across a phone, where the useful ones were the hardest to hit.
Three now, the same three.
- Long press on an episode plays it immediately. Tap still opens it.
- The show's name in the episode screen's top bar opens the show. It was sitting there doing
nothing.
Extracting `rememberShownEpisode` kept EpisodeScreen inside its length budget -- the follow-the-
player logic is a named concern and reads better out of the way than the top bar did.
All four checked on a device, not just in tests: the three icons, a long press starting playback
at 0:06, and the title tap landing on the show.
The coverage gate pushed back and found one more gap on the way: a fetch failing with no message
fell through to the exception's class name, and nothing exercised it. Tested now. The exception
covers only the deleted method's own tests.
1065 tests, detekt 0, lint clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Peter's mobile data is not metered, so the UNMETERED constraint on the refresh worker was doing
nothing for him -- and the question was whether to mark the connection metered or drop the guard.
Neither, quite.
The guard was never about the cost of refreshing. Its own comment said so: "refreshing every feed
is cheap, but downloads that follow are not". It was a proxy for a guard on downloads, and the
refresh is genuinely cheap -- every request carries If-None-Match and If-Modified-Since, so an
unchanged feed is a 304 with no body and a whole sweep is a few hundred bytes a show. Waiting for
Wi-Fi to spend that is what let a failed refresh from hours earlier stay on screen as the current
state of the library.
So refresh runs on any connection now, and the download worker keeps its unmetered and
battery-not-low constraints, which is where the expensive unattended work actually is.
Marking the connection metered would have worked too, and is worse: it is a system-wide setting
changed to compensate for one app, affecting every other app on the phone.
**What the guard was really hiding, and still is:** anything reaching the queue calls
Scheduling.downloadNow, which has no network constraint at all -- deliberately, because Peter
asked for things he queues to download immediately including on mobile data. An auto-queue rule
firing during a background refresh goes down that same path, so a rule can start tens of
megabytes unattended on cellular. That is fine while his data is unmetered and is the real hole
if it ever is not; distinguishing "I queued this" from "a rule queued this" is the fix, and it is
not in this commit.
1059 tests, detekt 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
The security review of the last commit found a confused deputy in it, and it was right. The
bearer was attached on origin alone, and `episode.mediaUrl` comes out of a feed -- so a publisher
could set an enclosure URL on the skip server's own origin, match the origin check exactly, and
have the app issue an authenticated GET to a path of their choosing, storing whatever came back
as that episode's audio. Confirmed by writing the test before the fix: it failed.
Provenance is the first gate now and origin is the second. `authenticated = true` is passed only
for the URL `ServerAudio.resolve` returned, never for one that arrived in a feed, and the origin
check still has to agree -- so a server answer naming some other host is fetched without
credentials rather than sending them there. Both gates have a test, including the case where they
disagree, and OutboundRequestsTest pins that exactly one call site in the app is authenticated.
**Chasing the coverage gate honestly turned up a real bug**, which is the second time this week
it has earned its keep. Rather than file a third COVERAGE_EXCEPTION I went to see what the
uncovered lines in EpisodeDownloader actually were, and wrote a test for the empty-response
branch. It failed: OkHttp hands back an empty body rather than null for a 204, so the existing
`?: run { ... }` guard never fires, the copy writes nothing, and a **zero-byte file is renamed
into place and recorded as DOWNLOADED**. That episode then fails to play and reads as the
publisher's fault. The length is checked after the copy now, which catches a 204, an empty 200
and anything else that arrives with no bytes.
Also tested while there: the media-type to file-extension mapping, which had none. Not cosmetic
-- Media3 picks an extractor partly from the extension, so a .audio file holding mp4 bytes is a
plausible way to get an episode that will not play. Parameters and capitals included, because
feeds send "audio/mpeg; charset=utf-8" and "AUDIO/MPEG".
1059 tests, detekt 0, lint clean, coverage 94.09% -> 94.28% with no exception.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Peter opened the app to six shows in red, every one of them "Could not reach that feed". All six
answered 200 when checked from the server a minute later.
His guess was an auto-refresh on focus. There isn't one -- the only triggers are the button and a
six-hourly worker -- and that makes it worse rather than better: what he was reading was a stale
report of a connection that dropped during a background refresh hours earlier. The line survives
until that feed next succeeds, and the worker is constrained to unmetered networks, so on mobile
data "next" can be a long time.
The error is still worth storing. A feed that has quietly stopped working is indistinguishable
from one with nothing new, which is the whole reason it is kept rather than logged. But one miss
is noise and a pattern is information, and the difference was already recorded: lastFetchedAtMs
is the last time the feed actually worked. So a failure appears once a feed has gone a day
without succeeding -- about four missed attempts at the current cadence. A dropped connection
clears itself and is never seen; something genuinely broken still goes red, the same day.
No schema change; the timestamp was already there.
Two cases stay loud on purpose. A feed that has *never* succeeded reports immediately, because a
subscription that failed the moment it was added is not a blip and somebody is waiting on it. And
a success timestamp in the future is treated as no success, so a clock that has moved cannot hide
a real failure indefinitely.
Not changed, and worth deciding separately: the periodic refresh is unmetered-only, so on mobile
data the library goes stale with nothing saying so. Adding a refresh on launch is now safe in a
way it was not before -- the transient failures it would produce are exactly what this hides --
but it is a behaviour change nobody asked for.
1052 tests, detekt 0, lint clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Groundwork for the server to stop putting the token in /audio/ query strings. Apache no longer
writes those to disk, but a secret in a URL still travels through everything that handles the
URL, and the only reason it was there is that the downloader had no credentials of its own.
Now it does: ServerAudio gains bearerFor(url), ServerSkips answers it from the configured
address, and downloadOverHttp attaches Authorization: Bearer when it gets one.
**The whole risk is in that one question.** This downloader also fetches episodes from whatever
CDN a publisher nominates, so an over-eager match would hand Peter's server credentials to every
podcast host he subscribes to -- strictly worse than the leak being closed. ServerBearer
therefore compares parsed origins, scheme and host and port, never the text of the URL, and the
test is mostly near misses: a host the server's name is a prefix of, one it is a suffix of, the
name appearing in a path or a query or a fragment, a userinfo section ending in the server's name
before an `@`, a downgraded scheme, a different port. Several of those pass a naive string match.
One of those tests needed a test of its own. `https://listen.terst.org@evil.example/...` is
refused, but it would also be refused if the URL simply failed to parse -- in which case the
origin check was never reached and the thing I thought I was testing was untested. So there is an
assertion that it parses, with host evil.example and username listen.terst.org.
The wiring test runs the real ServerSkips rather than a fake and checks the publisher path sends
no Authorization at all, because the unit tests above it fake exactly the component whose
host-matching is the safety property.
The server still emits the query token; this has to be installed before that stops, or downloads
401 in the gap. That is the next change, not this one.
1052 tests, detekt 0, lint clean, coverage 94.06% -> 94.09%.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
/audio/ URLs carry the token as a query parameter, because a URL handed to a downloader has to be
usable as it stands, and the "combined" log format logs %r -- the whole request line. So every
audio fetch wrote the token in plaintext into a file logrotate keeps fourteen copies of: 335 lines
across fifteen files, going back months.
Both vhosts now use a listen_noquery format, %m %U %H instead of %r, which keeps the method, path
and protocol and drops the query. Verified with a canary token against the live server rather than
by reading the config: the request logged as "GET /api/health HTTP/1.1" with nothing after it, and
a real request from the phone logged cleanly alongside it. The existing logs are redacted in place,
gzipped ones included, with their lines and timestamps intact -- zero occurrences left, and none
anywhere else under /var/log.
The token itself is worth rotating, since it sat in those files for months. That is Peter's call.
The real fix is for the audio URL to carry no token, which needs the downloader sending a bearer
header for skip-server hosts -- and only those, or the token would go to every podcast CDN it
fetches from. That app change has to be installed before the server stops emitting the query
token, so it is not in this one.
Config only; no code changed. Written up in CLAUDE.md so nobody restores "combined".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
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
|
|
**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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|