| Age | Commit message (Collapse) | Author |
|
The plan made this the gate: a linear scan of N stored signatures
against each episode does not survive, so the library is only possible
if an index can shortlist candidates cheaply. It can.
Built as a throwaway harness against the live corpus. The episodes the
plan named had rotated out of the server's 8-episode window, so the
test set was rebuilt from current audio and came out stronger than the
one it replaces -- 6 true repeats and 16 non-repeats, against 2 and 3.
Six repeats scored 813-1564 votes; sixteen non-repeats scored 27-94.
The narrowest separation is 8.6x, where the correlator it replaces
manages 4.9x on the same audio. Nothing sits in the gap.
Offsets land within 1.3s and every proposal is cheap to confirm by
correlating against a short window at the claimed position, so the
shape is: index proposes, correlator disposes. That is not a
formality. In one episode the advertisement occurs twice and the index
picked the weaker instance, which only a confirming pass would catch.
Measured: 9.8ms to hash a signature when it is stored, 196ms to look
one up against 23 episodes, ~8s per episode to index, 353MB for 23.
Five hundred signatures cost 98 seconds where linear correlation costs
about 63 minutes.
The harness is not committed -- it read absolute server paths and had
no tests, and its output was an answer rather than code to keep. The
parameters it found are written into the plan so it can be rebuilt
test-first, along with the one weakness worth knowing: the hashes
collide heavily, and that density is what makes a lookup cost
milliseconds rather than microseconds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
The "this is an ad" press jumped 45 seconds, a number argued from
ad-unit arithmetic rather than from the controls that already exist.
Peter: "the jump should stay 25 seconds. Don't go inventing new
timing."
Skipping forward is already what you do to an advertisement, so the
button goes the same distance and additionally says why. One number to
know, and a second press goes the same distance again.
The amount had three definitions already -- the player's increments,
the episode screen's transport buttons, and the notification caption's
hardcoded "Forward 25 seconds" -- and this nearly became a fourth with
a different value. They now all read one, and the captions are derived
from it so a caption cannot name a number its button does not do.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
The notification carried four buttons doing two jobs: Media3's default
previous/next (which PodcastPlayer redefines as seek) plus our own
back-15/forward-25 pair. Media button preferences with explicit slots
let the custom pair claim the two seek slots, so the duplicates go and
the freed space carries "this is an ad". No player command is removed
to achieve it, which is the route that once silently killed the
headphone skip button.
One press means four things, told apart by where the playhead lands
rather than by guessing: press claims advertising from here and jumps
past it, pressing again extends the same claim, coming back inside the
jump moves its end to where you landed, and coming back past the press
withdraws it. Outside a 20-second window a seek is ordinary navigation
and says nothing. The window is stored with the label so changing it
later cannot reinterpret old data.
Every backwards seek feeds the gesture, whatever caused it --
notification, headphone remote, in-app bar, dragged slider -- because
you correct with whatever is to hand.
This is the opposite evidence to the tap-a-break popup. That one can
only correct breaks detection already found, and detection accounts for
about 38% of the advertising the feeds imply.
Verified on the emulator: the notification now shows exactly back-15,
pause, forward-25 and the new button; the lock screen shows it too and
pressing it there records a claim without unlocking; all four gesture
arms behave as specified; and the media transport keys still seek by
25s and 15s. Android Auto still needs the car.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
The server has computed and stored Partners since ad detection shipped --
state.json carries it on every break -- but breakRange never put it on the
wire, so the phone's new tap-a-break popup could only ever say "the server
did not say how many other episodes carried this audio". That count is the
one piece of evidence a listener can weigh before judging a break, and the
popup exists to show it.
My brief for the popup asserted the server already sent it. It did not.
The implementing agent checked rather than taking my word, which is why
this is a two-line fix now instead of a wrong "why" line forever.
Also lands the handover plan for the correction channel and the signature
library, written before a context compaction, including the gesture design
Peter specified for the notification button and the jump-size argument he
correctly rejected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Keeps partners on episode_midrolls (schema 15), stores verdicts in their own
break_verdicts table so a server refresh cannot wipe them, and drops invalidated
breaks from skipping on the next tick. Verdicts are local only.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
The top bar now names the show (from the already-loaded subscriptions
list, "Episode" if unknown) and the body keeps the episode title, wrapping
to two lines. Chapters are no longer fetched when the screen opens; the
overflow offers the entry whenever a chapters URL exists and the dialog
fetches (cached) on open, saying so if the file is empty. Playback's
ad-chapter path (PlaybackAdvancer/PlaybackSources) is untouched.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Opening an episode now puts you in its transport at the stored position;
pressing play changes nothing about the layout. One primary control
(Play/Resume/Pause) with back/forward, a speed chip that opens the four
offered speeds (stored 1.5/1.75 still play and are named on the chip),
and everything secondary behind a top-bar overflow: queue, download,
played, watch, mark-older, chapters, and the publisher-copy action
(behind a dialog carrying its warning). Description shows about two
lines, the rest on tap.
The now-playing bar, library bar, queue, share receiver and nav graph
all open the playing episode's screen. PlayerViewModel stays the single
owner of playback state; no playback decision moved into the UI.
PlayerScreen.kt and WatchButton.kt are deleted. Coverage drop, if any,
is from removing covered dead code.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
The 12s floor cut real detection on shows with verified ads -- LeVar
Burton Reads fell from 19.0 to 10.5 minutes found against a 26.6 minute
gap -- so 8s looked like the fix. Measured on an isolated copy of the
live data it is not: Sleepy History at 8s finds 174 minutes of
"advertising" in a show that has 48 seconds of it, worse than the 4s
floor that produced Peter's original complaint.
MinRunSamples and MergeGapMs interact and cannot be tuned separately. A
low floor lets noise fragments survive and the 60s bridging then welds
them into enormous spans.
Had only LeVar been measured the floor would have been lowered.
Also re-pins .coverage-baseline, which the hook left modified after
d96c876 deleted covered readout code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Served / declared / cut on one line, a two-colour legend, and marks on the
track that name themselves and seek when tapped. The per-break list and the
permanent-None intro/outro rows are gone.
Coverage: dropped 94.23 -> 94.15 because covered readout code was deleted.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
CLAUDE.md still described it as deliberate design with its original
rationale, which would have had a future session reintroduce the exact
behaviour Peter asked to remove. Also records the three cases that must
keep downloading directly, why a rejected token is permanent rather than
transient, and what audioFromServer is for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
HTTP 401/403 from the skip server now downloads the publisher's copy at once
(audioFromServer=false), as if no server were configured. Unreachable, timeout,
404, 5xx, unparseable and pending still wait. The transport reports Body /
Rejected / NoAnswer and makes no decisions. The rejection is remembered (from
episode requests and pushSubscriptions) and shown in the skip-server settings
until a different token is saved or a request succeeds.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
With a skip server configured, a podcast episode it cannot supply yet (pending,
unreachable, bad answer, or audio that fails) now enters WAITING_FOR_SERVER and is
retried on every refresh, instead of falling back to the publisher's differently-ad-
inserted file. Taking the publisher's copy is an explicit action on the episode screen
that sets audioFromServer=false. No server configured, YouTube/Facebook and unresolvable
feeds still download directly. No schema change: downloadState is stored by name as TEXT.
detekt: EpisodeDao and ListenViewModel function-count thresholds raised by one (rationale
in detekt.yml).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
The sweep awaited each episode's server query sequentially, and it runs on
every refresh including the library button. Several hundred downloaded
episodes meant a tap that used to take seconds took minutes, and under
WorkManager it risked being killed partway -- the same shape as the
ten-minute ceiling that stopped FingerprintWorker ever confirming.
Six at a time, through a semaphore inside the call's own coroutineScope so
nothing escapes to a background dispatcher. Per-episode failure isolation
is unchanged; CancellationException is rethrown rather than caught, the
bug already fixed once in skipFurniture().
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
publisher audio
A feed refresh never re-asked the skip server for already-downloaded episodes, so
detection improvements only ever reached a show by deleting and redownloading it.
Fixing that safely needs to know which episodes are safe to re-query: a show using
dynamic ad insertion serves a different file on every request, so a server-measured
offset only applies to the server's own copy, not whatever the publisher fallback
left on disk.
- EpisodeEntity.audioFromServer (schema 13->14, MIGRATION_13_14) records which source
actually landed; EpisodeDownloader sets it, defaulting to false everywhere else so a
requeue, retention reclaim or failed re-download clears a stale claim the same way
they already clear localPath.
- New SkipRefresher re-queries downloaded, server-sourced episodes via the existing
ServerSkips.resolve/record pair, one episode's failure not blocking the rest. Wired
into SubscriptionRepository.refreshAll via a new refreshSkips collaborator -- the
choke point every refresh route passes through, including the library's refresh
button.
- SkipSummary's episode screen readout gets a distinct line for a download that can
never be refreshed in place, instead of the misleading "Not checked yet".
Migration verified on-device (gradle-verify connectedDebugAndroidTest, MigrationTest:
14/14). Full connected suite: 31/31. Unit suite: 682/682. detekt/lint clean; coverage
93.86% against a 93.85% baseline.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
The toggle chooses what happens to whatever is being cut; the switch above
it chooses what that is. With both on it races through the show's own
intro and credits too, so labelling it "ads" undersells it. Caught in
review -- the labels are the only explanation this feature ever gives.
|
|
The artwork spanned y 46..80 (centre 63) against the 108dp canvas's
own centre of 54 -- 9dp too low, as measured. Shifted every y
coordinate in ic_launcher_foreground.xml up by 9: the arc's M and A
commands, and the M of each earcup path (the rest of each path is
relative, so only the start point needed to move). Now spans y
37..71, centre 54, matching the horizontal centre and well inside
the adaptive icon's 18..90 safe zone. ic_launcher.xml and
ic_launcher_round.xml both reference this one drawable, so no other
file needed a change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
1.1 and 1.25 cover the gap 1x leaves and 2x is the fastest speech
stays easy to follow at; 1.5x and 1.75x are dropped. A show, episode
or rule speed stored before this change keeps playing at its old
value -- the speed pickers just don't highlight a chip for it, same
as any value typed outside the list already worked. speedLabel() is
made internal and covered by a test; Float.toString() already
formats 1.1x correctly on the JVM, so no formatting fix was needed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
skipShowFurniture still decides which breaks get acted on; the new
fastPlayBreaks preference (default off, matching today's behaviour)
decides what the action is once a break is already due to be acted
on. PlayheadPolicy.decide is restructured into tiers (finish, break,
chapter, fallback-speed) chained with ?: so Finish still beats both
skipping and fast-play, and to stay under detekt's return-count limit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Measured against the owner's 334 mirrored episodes, the symmetric
durationSlopMs rule from the previous round costs far more than it
protects: 31% of episodes are served shorter than their declared
length (feeds are sloppy by tens of seconds -- one show ran -47s,
-33s, +88s, -95s across episodes), and every break in every one of
them fell to the unjudgeable default and got labelled "Ad break" --
including closing credits at 37:41 of a 38:25 episode. The owner's
furniture toggle did nothing on nearly a third of his library.
You cannot insert advertising into a file and have it come out
shorter, so a negative gap is evidence that nothing was inserted,
not evidence the feed can't be trusted. Classify's insertion test is
one-sided again: inserted := servedMs<=0 || declaredMs<=0 ||
(servedMs-declaredMs) > adUnitMs. durationSlopMs (2000) is replaced
by adUnitMs (15000) -- advertising is sold in ~15s units, so an
overrun under one unit is the feed being approximate, not an
insertion. Both unjudgeable cases (servedMs<=0, declaredMs<=0) are
unchanged; furnitureKindAt's position logic is unchanged.
This reverses the FIX-1 decision in this same file that made the
test symmetric, on the theory that a feed wrong in the short
direction couldn't be trusted to judge the other direction either.
Rewrote the two pre-existing tests that encoded that decision
(TestAnEpisodeShorterThanDeclaredIsAlsoAllFurniture, folded as a
duplicate, and the renamed
TestAServedLengthShorterThanDeclaredByFarIsStillFurnitureByPosition)
plus one comment on an unchanged assertion, and added coverage for
the new threshold's band (a 10s overrun is now furniture, not ad).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Owner reported a 38-minute episode with 37 breaks, 14 of them exactly
4.0 seconds and scattered roughly one per minute through the whole
thing: "This is madness. Ads are usually multiples of 15 seconds,
grouped together in a few spots." He was right. DefaultRunOptions's
MinRunSamples: 1000 is exactly 4.0s at the 250Hz discovery rate, so
anything that long was taken seriously -- that floor, not a real ad,
is why 14 breaks landed on precisely 4.0s: correlation noise pinned
at the floor.
Measured across 333 mirrored episodes: episodes the feed proves carry
advertising (served >=60s longer than declared) have a median of 3
breaks, max 7. All six badly-fragmented episodes in the corpus belong
to one show (Sleepy History), which reuses long ambient music beds --
the detector was matching the programme's own texture against itself.
Fix, validated against the declared-vs-served length gap (a measure
the detector does not produce and so can't flatter itself with):
- MinRunSamples 1000 -> 3000 (4s -> 12s). 12 rather than 15 so a real
15s ad whose correlation lapses at one end still survives; both
floors were measured and 12 scored better.
- AdConfig.MergeGapMs 10_000 -> 60_000, so fragments of one real ad
pod separated by more than 10s bridge into the single break a
listener experiences, rather than staying fragmented.
Corpus-wide: 1091 -> 539 breaks, agreement with the length gap 31% ->
34%, unverifiable skipping on no-insertion shows 73min -> 64min. Half
the breaks removed and accuracy up is the signal they were noise.
Both constants' doc comments rewritten to state the new value and why,
not just the mechanics -- the comments are the documentation here.
TDD: added TestDefaultRunOptionsFloorIsTwelveSeconds (no pin existed
for MinRunSamples) and updated TestDefaultAdConfigUsesTheValidatedValues's
MergeGapMs pin; both confirmed red against the old constants before the
change. Three existing ads_test.go fixtures built at or under 10-12s
were lengthened to clear the new floor (not weakened). Added
TestScatteredSubFloorFragmentsProduceNoBreaksButARealPodStillDoes,
confirmed it fails against the old constants and passes against the
new ones.
gofmt, go vet, go test ./... all clean (server/ only -- no app/ changes).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Fix 2's runCatching caught Throwable, including CancellationException.
Skipping quickly between episodes cancels PlaybackService's context-load
coroutine mid-read; the swallowed cancellation let that launch carry on
and overwrite the new episode's context with the previous one's midrolls
and skip flag, so the playhead could be moved by the wrong episode's
breaks. Narrowed to an explicit try/catch that rethrows
CancellationException and catches IOException specifically (covers
CorruptionException, a subclass) rather than a generic Exception, with
the exception logged instead of silently discarded -- keeping the
original crash-guard finding intact.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Six fixes surfaced only by a whole-branch view of the kind/furniture work:
symmetric served-vs-declared comparison in Classify so a short-served
episode (e.g. 30:00 declared, 29:30 served) can no longer read a real
pre-roll as the show's own intro; a runCatching guard around the DataStore
read backing skipFurniture() so a corrupted preferences file can no longer
kill the media service at episode start; a proper isAdvertising predicate
on SkipSummary so the episode screen's ad/furniture colour split no longer
depends on comparing against a renamable display string; a stale comment
in PlayheadPolicy describing the pre-toggle behaviour; the already-unreachable
servedMs<=0 guard in furnitureKindAt now fails safe to KindAd instead of
KindCue; and an ORDER BY on getRanges so kind-filtered lookups are
deterministic. Throughout: anything unknown, empty or unjudgeable behaves
as advertising, never as furniture.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Settings now has a switch for skipping a show's own intro, break cue
and credits, following the existing Compose state-holder pattern
(rememberFurnitureSkipSettings) rather than inventing a view model --
there isn't one in this app. Bundled with SkipServerSettings into a
single SkipSettings parameter to stay under detekt's parameter-count
limit, since both live in the same settings section. Advertising has
no toggle and stays always-skipped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
111 episodes reported 'Ads inserted' about a show's own opening theme and
closing credits.
|
|
An unrecognised kind counts as advertising, so a kind added by a newer
server does not quietly stop being skipped by an older app.
|
|
On by default, because skipping every detected break is what the app
already does.
|
|
Defaulted to advertising so a server that predates kinds still parses,
and because that is what such a server meant by every break it sent.
|
|
Existing rows become advertising, which is what they behaved as: every
row in this table was skipped unconditionally before kinds existed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
The phone filters on it: advertising is always skipped, the show's own
audio by preference. Without the kind on the wire every break arrives
looking like an advertisement.
|
|
Finding 1: Treat zero or invalid servedMs as unjudgeable, falling back to
advertising classification to match the constraint that anything unjudgeable
must be conservative (ffprobe duration probe returning 0 is plausible upstream
defect).
Finding 2: Use proportional edge windows for short episodes (< 240s) to avoid
misclassifying interior and closing breaks as intro when opening and closing
windows overlap. For a short episode with breaks at 0s, 50s, 90s in a 100s
episode, classify them correctly as intro, cue, outro instead of all intro.
|
|
An episode no longer than its declared length had nothing inserted into
it, so whatever recurs in it is the show's furniture however strongly it
matched. 138 of 322 mirrored episodes are in that position and the scan
published ad breaks on 111 of them.
|
|
Eight tasks covering the spec decisions end to end: the server decides a
kind for every break it publishes, the phone stores it, advertising is
always skipped and a show own intro, cue and credits follow one
preference, and the readout names which is which.
The cue prior and the furniture library are deliberately not planned
here. Each is independently shippable, and the cue signature thresholds
should be chosen against kinds visible in real data rather than guessed
at before any exist.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Reported from the phone: Show 62 of Hardcore History failed with
ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED. Android has refused cleartext
since API 28 and this app declares no exemption, so any enclosure
published over http fails the moment it is streamed rather than played
from a downloaded file. Dan Carlin's older episodes are published that
way while the recent ones are not, which is why it went unnoticed; the
manifest has not changed since long before the skip server, so this is
not a regression from that work.
Upgrading the scheme rather than permitting cleartext: that exact URL
answers 200 over TLS and a range request returns 206 audio/mpeg, podcast
CDNs serve the same path either way, and an exemption would weaken every
other request the app makes for the sake of a few old enclosures. A host
with genuinely no TLS is no worse off, since cleartext was refused
already.
Two paths, because they do not share a stack. Everything that goes
through OkHttp -- downloads, feeds, chapters, artwork -- is covered by an
interceptor on the one client the graph builds, which is the right home
for a property of the app's networking rather than of any one feature.
ExoPlayer has its own data source, so playback converts the URI as it
builds the media item.
Putting the conversion at the downloader's call site instead broke four
of its MockWebServer tests, which was the tests making a fair point: a
global network policy does not belong at one call site. Components under
test build their own client and are untouched by the interceptor.
Also stops the server starving on-demand episode requests. The poll loop
and the request queue share a goroutine, so an episode the phone asked
for waited out the whole cycle -- hours across 42 shows, which is why the
tap that produced this report registered the episode and never fetched
it. Now drained before each feed, bounding the wait at one feed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Approach 2 chosen, built in the phase order already proposed -- it is a
superset of approach 1, so the ad-detection work still lands first and the
library last.
Advertising is always skipped; the show own intro, cue and credits obey a
single preference defaulting to on, because that is what happens today.
The toggle settles a bootstrapping question the design had left open. A
furniture clip cannot be promoted until a second fetch occasion has
passed, so until then there is nothing to label with. The zero-gap
classification fills that gap: served length equal to declared means
nothing was inserted, so every break is furniture and position decides
which kind; otherwise a break is advertising unless a promoted clip says
otherwise. Day one is correct on the 138 zero-gap episodes and identical
to current behaviour everywhere else.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Three defects turn out to be one missing distinction. Ad detection finds
a median 67% of the advertising the length gap implies, because it can
only see what two simultaneously-mirrored episodes share. It reports a
show's own intro, break cue and credits as advertisements on 111 of the
138 episodes that had nothing inserted into them. And intro/outro
offsets stay withheld for want of independent evidence.
Peter's observation about theme music around ad breaks is the clue, and
probing it showed both what it gives and what it does not: the cue is
real, already detected, and marks a break's entry but not its extent --
scanning to r=0.6 finds one isolated hit per slot in ad-laden and ad-free
copies alike, so there is no closing cue to bracket against.
Writing this up changed the recommendation. The furniture library is only
needed for the intro/outro problem; the mislabelling falls out exactly
and for free from served length versus declared, and cues are
identifiable from their signature within a single batch. So the proposal
is now the cheap approach first, with the library kept for later and only
if the withheld offsets are wanted.
Nothing here is built. Review document, not an implementation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
A pod of several advertisements is found as several matches, because
agreement lapses between them. Published unbridged, 45% of the gaps
between consecutive breaks came in under ten seconds and 152 of 262
multi-break episodes contained one -- which plays as a skip, a one-to-
nine-second burst of advertisement, then another skip. Bridging also
moves agreement with the declared length gap from 69% to 73% median,
because what was bridged was advertising too.
MaxBreakMs now applies to the matches rather than to what bridging makes
of them. Applied after bridging it refused five episodes that bridging
had pushed over the four-minute cap, Radiolab's Grass Apes among them --
a 3:55 pre-roll and an 0:18 tag two seconds apart fuse to 4:15, and the
episode the whole chain was device-verified on would have published
nothing. One enormous match is evidence of shared programme; a chain of
ordinary matches a beat apart is evidence of an ad pod. MaxSkipFraction
still guards the episode as a whole.
Also pins the ad scan's production values, which were measured rather
than picked, and records what probing Peter's theme-music observation
turned up: the cue is real and already detected, it marks a break's entry
but not its extent, a second fetch is not an ad-free reference, and 138
of 322 mirrored episodes contain no inserted advertising at all while the
scan reports breaks on 111 of them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
The first full pass over all 42 feeds finished, then the server kept
re-deriving the same answers: Radiolab reported "53:29 of 59:39" four
times in one day. pollOne called detect unconditionally and the mirror
skips files it already has, so nothing had changed between passes. At
~6.5 minutes a show that is 4.5 hours a cycle against a two-hour poll
interval -- the machine never finished a cycle and never idled, burning
13h42m of CPU in 11h of wall clock, roughly 60% of it on the intro search
whose results are deliberately not published.
Show.DetectedBatch records the episode ids a completed pass examined.
The batch is the unit rather than the episode because every offset and
every break comes from comparing an episode against the others fetched
alongside it, so one arrival or one pruning legitimately changes all of
them. Order-independent, since feeds are not reliably sorted. Recorded
only on completion, so a cancelled or failed pass is retried.
Episode.DetectedAtUnix could not serve as the gate: it is only set for
episodes appearing in result.Offsets, so an episode nothing was found in
never got a timestamp and the gate would never close.
Also records what the first full corpus showed, including a disproven
hypothesis worth not retrying -- filtering breaks by partner count or run
length looks obviously right and makes agreement with the declared length
gap substantially worse -- and a correction: Sleepy History's 117 minutes
of "advertising" was a show-level sum sitting almost entirely inside the
two episodes its own guard already refused.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
The property that broke intro detection is the property that makes this work.
Eight episodes mirrored within one minute are maximally dependent, because the
ad server inserts whatever campaign is running into all of them identically.
For intro detection that destroys the independence the confirmation threshold
needs -- which is how it came to confirm an advertisement on 8 of 8 episodes.
For ad detection it is the signal.
And unlike intro detection it is checkable against a number the detector does
not produce: the feed declares the ad-free length, the file served is longer,
and the difference is the advertising.
**Measured on the live deployment, Radiolab's eight mirrored episodes: the scan
accounted for 53:29 of the 59:39 the length gap implies, with every individual
episode between 71% and 105%.** That is the first time detection in this
project has been checked against external ground truth and agreed with it.
The old sliding search could never have done this -- one full-length
correlation per window position, 199 seconds for a 240-second clip. BestLags
computes the cross-correlation over every lag in a single FFT pair, which says
where two recordings align; MatchedRuns then walks the extent of the agreement
directly, window by window, with no transform. Sized against measurements
rather than guesses: 2^22 is 1.69s, so a 70-minute episode decimated to 250Hz
is about five seconds a pair. Silence is explicitly not agreement, or separate
advertisements weld together across the pauses between them.
Breaks publish as ranges into episode_midrolls, which MidrollSkip and
PlayheadPolicy already read -- no playback changes and no migration. Intro and
outro offsets are still computed and logged but no longer published, and the
ad-derived ones already in the server's state were cleared.
Two guards publish nothing rather than something wrong: an episode whose
matches cover over 35% of it, and any single match over four minutes. A
re-aired episode or a two-parter would otherwise be eaten. Both record the
refusal, because "found nothing" and "refused what it found" are different
states and conflating them is the readout mistake over again.
Also three data-loss bugs, found by reading the journal after a restart rather
than by any test. The subscription list -- the one thing the server cannot
rediscover -- was persisted only when a whole poll cycle finished, which for 61
feeds is hours. A cycle re-applied its stale snapshot at the end, which would
undo a push that arrived during it and then delete the audio of every show the
snapshot omitted. And detection ignored cancellation, so systemd's 90-second
stop SIGKILLed the process before the save-on-exit could run. All three fixed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
The server is Peter's design and it works: listend polls the phone's feed
list, mirrors the newest 8 episodes per show, detects across all of them at
once, and serves the offsets *and the audio they belong to*. Deployed on
tungsten behind Apache at https://listen.terst.org. FingerprintWorker and
MidrollWorker are deleted -- the battery drain is gone because the work is.
Serving the audio is load-bearing. Dynamic ad insertion turned out to be on
every episode: Grass Apes declares 22:05 and the server was served 28:18. An
offset measured on one copy is minutes wrong for another, so the phone has to
play the bytes the detector examined. The offsets land in the same columns the
on-device detector wrote, so playback needed no changes and there is no
migration.
**The headline finding is a failure, and it is proved rather than suspected.**
Detection reported the intro confirmed on 8 of 8 Radiolab episodes and passed
its self-check. It had locked onto an advertisement. The chosen segment was
searched for in an ad-free copy of the same episode fetched hours earlier:
r = 0.0621. It is in the ad break, not the programme.
The confirmation threshold assumes three *independently fetched* episodes.
Mirroring 8 within one minute from the same ad server makes them maximally
dependent -- whatever campaign is running is in all of them, identical -- and
SelfCheck cannot catch it, because the segments genuinely are the same audio.
Moving to a server fixed the cost problem and broke the independence the
safety mechanism rests on. The published offsets are head-window matches, so
they happen to skip pre-roll advertising, which is useful by accident and will
move whenever the campaign rotates. It is not intro detection and is not
described as such anywhere.
The fix, not made: record when each episode was fetched and require the
agreeing episodes to span more than one fetch occasion. Reasoning, and an
argument for detecting the ads directly instead, in docs/detection-findings.md.
Also recorded there: two tooling traps that each cost real time. `android-ui
tap` can land on the soft keyboard, because the dump reports layout
coordinates the IME does not move -- a Save button and a text field both
appeared tapped and neither fired. And the server's token is alphanumeric now
because `adb shell input text` ate the leading "-" of a base64url one and
produced a silent 401 that looked like broken wiring.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Peter's report: the phone runs hot, uses a lot of battery, playback takes a
moment to start, and intelligent skipping has never done anything.
**The hot phone and the dead detection were one bug.** The candidate search
correlated 117 window positions against the whole of the other clip,
re-transforming that unchanged clip every time -- 199 seconds per episode end,
measured on a desktop, so minutes more on a phone. FingerprintWorker is a plain
WorkManager worker, Android stops one at ten minutes, and nothing was persisted
until a comparison returned. So it was killed mid-flight, saved nothing, and was
re-enqueued to start over, forever. IntroOutroDetectorScaleTest is the tripwire
whose absence allowed this: the unit tests ran on 300-sample clips while
production ran on 1,920,000.
Fixed coarse-to-fine, plus AudioCorrelation.prepareTarget so the target clip is
transformed once per search rather than 117 times. 10.6x, and a test asserts the
chosen reference window is byte-identical to what a full-resolution search picks.
**Midroll detection could never have run at all.** decodeFull buffered a whole
episode at its native rate -- 434MB for a 13-minute episode, no largeHeap -- to
produce 6MB of 8kHz mono. It threw OutOfMemoryError into a swallowing
runCatching and the episode was then marked checked forever. MonoResampler now
streams the decode so the peak allocation is the output, and maxDecodableMs skips
a longer episode before spending anything and without marking it checked.
**Detection is moving to a server**, Peter's call, in Go to match podkit and
doot. server/ holds the ported core, verified three ways because this is the
second port of this maths and the first shipped a normalization bug: numpy's
literals extracted mechanically from AudioCorrelationTest, the FFT against a
direct DFT, and the whole correlation path against a brute-force Pearson.
On four real Radiolab episodes it confirms the intro on 4 of 4, at 48.0s, 8.65s,
8.65s and 65.15s -- the varying pre-roll, observed in working code at last.
skips.SelfCheck verifies located segments against *each other* rather than
against the reference, which is a different claim: correlation is not transitive,
so two segments can each clear 0.6 against a reference and be unrelated. It
rejected the outro on its first real run (r=0.746), withholding offsets that
would have ended three episodes early. Offsets are published only when they
verify.
Also: the episode screen gets a working scrubber with ad-break marks and a
declared-versus-measured length readout, queue rows open the episode instead of
doing nothing, PrepareTrace logs playback-start timing against episode length
for the still-open pause question, and -PphoneOnly drops the emulator's CPython
from a sideload build (114MB -> 99MB).
Full history and numbers in docs/detection-findings.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Peter's call (option 1 of the three in docs/detection-findings.md): exempt
an episode retention would otherwise reclaim while it is a show's pending
comparison basis, rather than extending the grace period or accepting the
gap.
RetentionSweeper gains a pendingEpisodeIds seam (suspend () -> Set<String>,
defaulting to empty) and filters its reclaim candidates against it before
the grace-period policy ever sees them -- every existing caller and test
was unaffected by the default. RetentionWorker wires the real thing: the
union of two new, narrow DAO queries --
ShowFingerprintDao.getAllPendingEpisodeIds() (intro/outro) and
MidrollDao.getAllMidrollPendingEpisodeIds() (midroll, reading
SubscriptionEntity.midrollPendingEpisodeId).
Applied to both fingerprinting mechanisms, not just the one that got
diagnosed: MidrollUpdater's discovery has the identical rolling-pending-
episode shape and would race retention the same way for a fast show, just
silently, with no frozen row to reveal it.
Not yet re-verified on a device -- the next live test is two Up First
downloads close enough together to actually compare, confirming
pendingEpisodeId advances to a real second episode instead of restarting.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sj3CoWEedM56bebByKgNxA
|
|
Triggered a fresh Up First download on the phone and watched
androidx.work.workdb and show_fingerprints immediately after, live. A new
FingerprintWorker job appeared and succeeded within seconds, and
show_fingerprints updated for the first time since 2026-09-27 -- but instead
of advancing toward a second comparison, it just replaced the stale pending
episode with the new download. confirmedCount stayed 0.
That's checkAgainstPending's own designed fallback when pendingClipOf can't
re-decode the stored pending episode because its file is gone. And it is
gone: RetentionPolicy's grace period is one day, and Up First is a fast,
daily, ~15-30 minute news show that gets played through quickly. By the time
a second episode is downloaded and processed, retention has very often
already reclaimed the first one's file -- so the detector never gets two
episodes' audio to compare at once, and keeps discarding its only candidate
and restarting from whatever the newest download is. Cruising the Waves
reaching a real Tentative candidate the same day is consistent with this: a
show whose downloads happen to land further apart, or whose previous pending
episode hadn't been played+reclaimed yet, doesn't hit the race.
This was never a signal-processing problem -- DEFAULT_REFERENCE_SECONDS,
MIN_SCORE and CONFIRM_THRESHOLD were the wrong place to look the whole time.
Wrote up three options (exempt a pending episode from reclaim, extend the
grace period for a show still building its first candidate, or accept the
gap for fast shows) as a decision for Peter, not something to just change.
Separately unresolved and now secondary: Up First's actual second download
(2026-09-28) left zero trace in show_fingerprints, which the live test's
fallback path would have if it had run at all -- most likely a deferred or
dropped one-shot WorkManager job, not reproduced live this session.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sj3CoWEedM56bebByKgNxA
|
|
The phone reconnected mid-session, so this moved from desktop simulation to
live device state -- pulled listen.db and WorkManager's own execution
history straight off it (read-only, via run-as + adb exec-out cat, nothing
modified).
The pipeline is not broken: show_fingerprints for Cruising the Waves Podcast
shows a real Tentative INTRO candidate (confirmedCount=2, a populated
referenceClipPcm) from an ordinary download today. Something does reach
Tentative on this exact build.
Up First specifically is stuck, and the evidence is more precise than
"nothing found yet" suggested. Its show_fingerprints row for both INTRO and
OUTRO hasn't updated since 2026-09-27T02:19 -- the moment its very first
episode was ever processed. A second real episode was downloaded, played,
and swept by retention the next day, and its midrollCheckedAtMs is set,
proving MidrollWorker's decodeFull read that exact file successfully that
day. But FingerprintUpdater.processEnd calls fingerprints.upsert() on every
outcome, confirmed, tentative, or discarded -- so if any comparison
involving that second episode had run at all, the row's updatedAtMs would
have moved. It didn't. The second episode's FingerprintWorker run either was
never enqueued, or decodeHead/decodeTail both returned null for it, and the
latter looks unlikely given decodeFull succeeded on the identical file the
same day. Read EpisodeDownloader.downloadOverHttp end to end looking for a
download-vs-playable race; there isn't one -- it's a blocking download to
.part then an atomic rename before onDownloaded fires.
WorkManager prunes completed WorkSpec rows after about a week, so the actual
historical FingerprintWorker run (if any) for that episode is already gone.
Narrows "What to check on the phone" to one live test: trigger a fresh Up
First download and watch androidx.work.workdb and show_fingerprints
immediately after, the same way this session just did.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sj3CoWEedM56bebByKgNxA
|
|
The brief's leading hypothesis -- an 8-second reference window too wide for
a ~6-second sting -- does not survive contact with real data. Simulating
IntroOutroDetector.findCandidate exactly (the same normalized_xcorr/best_match
math AudioCorrelation.kt uses, unmodified) against the four Up First episodes
already cached from the original prototype: every one of six pairs confirms
cleanly at both ends, at the app's real DEFAULT_REFERENCE_SECONDS=8 and
MIN_SCORE=0.6 -- intro r 0.79-0.998, outro r 0.994-0.999.
Also ruled out: the naive linear-interpolation resampler MediaCodecClipSource
actually uses. Reproduced it exactly against a fresh re-fetch's native 44.1kHz
audio and compared to a proper ffmpeg decode -- 0.9991 correlation, and the
production simulation confirms identically either way.
Two findings beyond the original brief:
- Pre-roll length varies a lot (44s-94s across four episodes) but the global
search already handles that; it isn't the blocker the first draft worried
it might be.
- The same episode URL, fetched fresh today against a copy from 2026-09-26,
is byte-identical for the first ~50 seconds and then diverges completely --
sharper evidence for "dynamic ad insertion is the environment" than the
Radiolab duration mismatch alone, though it lands after the useful intro
signal so doesn't threaten detection specifically.
Experiment 6 (re-check the Darknet Diaries trap) didn't need a new fetch --
the midroll prototype session's already-cached data answers it: the shared
~51s segment still matches at r=1.000 across a nine-year gap, which a truly
rotating per-request ad could not do by chance. Looks like a real intro, not
the trap the threshold exists to catch.
Net effect: the algorithm and its tuned constants are no longer plausible
suspects on real audio. What's left points at the specific on-device run
rather than the approach -- see "What to check on the phone" for the three
concrete things to look at that this sandbox has no way to check itself.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sj3CoWEedM56bebByKgNxA
|
|
The fingerprinting runs on a device now and has still confirmed nothing for
a real show. That is an open question, not a known bug, and answering it
needs the prototype rather than the app -- only the prototype can show what
a parameter change costs in false positives, which is the number that
matters here.
docs/detection-findings.md collects what this round established: that the
pipeline works, that the confirmation threshold earns its place, and the
finding that reframes the problem -- dynamic ad insertion means the same
Grass Apes came back 29:34 on one fetch and 27:39 on another, so ad content
and the offset of everything behind it differ per request. It lists the
experiments in order, including using insertedAdMs as ground truth for the
midroll scan, and what not to touch.
Linked from the known-gaps list, which is where a future session looks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
**The hand-off.** Sharing a timestamped YouTube link now opens Listen on the
player, already playing at that point. Two things were missing. The
timestamp was only honoured on first insert -- the insert ignores a clashing
guid, so every share after the first silently did nothing and playback
resumed wherever Listen had last left it, which is exactly the going-back-
and-forth case. And nothing started playing; the video landed in the library
to be found. Re-sharing now moves the position and unplays the episode,
because sharing a moment in something you finished is how you go back to it.
A share with no timestamp still just adds it quietly -- interrupting
whatever is playing would be rude.
**A regression in yesterday's duration fix, found by following Peter's own
observation.** He noted the 22:05-vs-29:34 gap was probably content versus
ads. It is -- and that has two consequences I had missed.
The first is that `EpisodeMatcher.mergedWith` rewrites `durationMs` from the
feed on every refresh, correctly, because it is feed metadata. Writing the
measured length into that column meant the correction survived until the
next refresh and then silently reverted. So there are two columns now:
`durationMs` stays the feed's claim, `actualDurationMs` is what the player
measured, and `playbackDurationMs` prefers the latter.
The second is that the wrong number was never only cosmetic. It sizes the
decode buffer a full-episode midroll scan allocates -- truncating the scan
by minutes on precisely the shows that have midrolls -- places the outro
offset, and drives the finished check, which would mark a 29-minute episode
played at 21 and advance the queue. Those all read `playbackDurationMs` now.
And the third thing, which is Peter's idea and free: the difference between
the two numbers is the advertising in the episode, with no audio analysis at
all. `insertedAdMs`, shown on the episode screen, and a check on the midroll
detector -- whatever it eventually finds should add up to about that much.
Measuring it turned up something worth knowing: the same Grass Apes came
back 7:29 longer than declared on one fetch and 5:34 on another. Insertion
happens per request, so this is a property of a file, not of an episode.
Verified on the emulator. Hand-off: cold start into the player at 12:39 from
a stored 12:30, and the warm onNewIntent path at 25:14 from 25:00. The
YouTube resolver itself cannot be exercised here -- this host gets a 302 bot
page for every watch URL, as CLAUDE.md already records -- so the new code was
driven through the intent it receives, with the resolver's own behaviour
unchanged. Durations: list showed 22m on a fresh subscribe, 27m after
playing, with 1325000 and 1659454 stored separately, and the episode screen
reading "Ads inserted ~5:34". 550 unit tests, 12 migration tests on device,
detekt and lint clean. Coverage 92.96% -> 93.02%.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Two from Peter, one of which turns out to matter more than it looks.
**The list said 22m, the player said 29:34.** The feed is lying, not the
parser: Radiolab publishes Grass Apes as `00:22:05` and the file ExoPlayer
opens is 29:34. Checked against the live feed rather than guessed. The seven
and a half minutes between them are dynamically inserted advertising -- the
feed declares the ad-free content length and the CDN stitches in the rest --
so the error is large, systematic, and worst on exactly the shows the
midroll detector exists for.
The player is the first thing in the app that sees the truth, so
PositionWriter now writes it back, through a new narrow PlaybackStateDao.
Once per episode rather than once per tick: this runs on the position timer
for the length of an episode, and a lookup on every tick is precisely the
per-episode work that does not scale. A two-second disagreement is left
alone, being a container arguing with an encoder rather than a lie, and an
unprepared player reporting nothing never wipes what the feed said.
PlaybackStateDao is a split by meaning, not by count -- it owns the two
things playback learns and writes back, the only writes in the app whose
loss is unrecoverable. It also keeps EpisodeDao under detekt's interface
limit, which the new query had pushed it over; the config's own comment
argues against splitting a DAO to satisfy a count, so this splits it for a
reason that would stand on its own.
**Tapping the notification opens the app**, via setSessionActivity. It had
none, so the transport buttons worked and the body did nothing, which reads
as breakage rather than omission.
Verified on the emulator against the real feed: Grass Apes stored as
1325000ms on subscribe, 1770553ms after twenty-five seconds of playback.
The notification now carries `contentIntent=PendingIntent{... startActivity}`
in dumpsys -- I confirmed the intent is attached and targets this app's
activity, but did not simulate the physical tap. That same dump corroborates
what I told Peter about the notification being Auto's doing and not ours:
importance=2, sound=null, vibrate=null, category=transport.
ExoPlayhead joins the coverage exclusions. It is four one-line getters over
ExoPlayer that happen to live in PlaybackService.kt, so it read as covered
by that entry while actually being measured -- it is the seam that makes
PositionWriter testable, not logic. 540 unit tests, detekt and lint clean.
Coverage 92.73% -> 92.96%.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Peter, on the sixty-second cap from the last commit: "won't be enough for
tangle or the daily show at least. Both have theme music around ads at the
end at least." He is right, and the cap was the wrong trade -- trimming a
clip to fit a database row truncates the very thing being identified, and
the music wrapping a break is part of what makes it recognisable.
So the clips leave the database. MidrollClipStore writes them as files
beside the audio library and the row holds a path, which removes the length
limit rather than negotiating with it: three minutes is 2.88MB, and a
CursorWindow hands back nothing over 2MB. Migration 10->11 drops existing
candidates instead of converting them -- a migration cannot write files, and
every stored candidate was written under the sixty-second cap, so it is the
wrong length anyway. They are a cache; detection starts again. The
intro/outro fingerprints are untouched, being eight seconds and correct.
StoredClip.fits now guards the one decoded clip still kept in a row, the
intro/outro reference, at the point it is written. Unreachable at the
current eight seconds, and there precisely because this table already
shipped a clip that was not.
Bedtime shows are gone from Android Auto's browse list too, as asked.
Driving is the opposite of falling asleep and a car list is scrolled blind
at a traffic light -- the same argument that already hides played episodes
there. Voice search still reaches them: asking for a show by name is
unambiguous about wanting it.
Found while doing it: unsubscribing never deleted anything from disk. The
dialog has always promised "episodes, downloads and playback positions", and
the rows did cascade -- taking with them the only record of where the
downloaded audio was, so the files stayed forever with nothing pointing at
them. Midroll clips would have joined them. Unsubscriber deletes the files
first and the row last, because the other order is exactly how the leak
happened, and it drops the row even if a file will not delete: a show stuck
in the library is the worse failure of the two.
Verified: 533 unit tests, detekt and lint clean, 11 migration tests on the
emulator. Coverage 91.84% -> 92.73%.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Four things Peter asked for, plus a crash found while verifying them.
- Bedtime shows. A per-show flag (schema 8->9) that takes a show out of the
library entirely; the moon icon in the top bar switches to listing only
those. Not a category system -- the one grouping worth building is the one
with behaviour attached, which is that entering bedtime mode also arms the
sleep timer. BedtimePolicy owns the two cases that make that safe: it will
not overwrite a timer set by hand, and leaving only cancels a timer bedtime
itself armed. The mode is deliberately not persisted; a restart is morning.
- Shows with nothing waiting are dimmed. Same condition the library sort
already used -- nothing unplayed and unqueued -- now returned alongside it
as SubscriptionListing.hasWaiting. That comes from a COUNT rather than the
sort key on purpose: an episode with no publish date ranks at 0, which is
right for ordering and would have greyed out a show with one sitting
unheard. LibraryOrderTest pins that case. The lastError line keeps its
error colour through the dimming, since a failing feed looks exactly like
a feed with nothing new.
- Per-show controls moved into an overflow menu and a settings sheet, and
the tutorial prose is gone. Reaching the episodes used to mean scrolling
past drip cadence and two paragraphs; Peter wrote the requirements and
does not need them explained back. Unsubscribe went into the same menu,
off the top bar where it sat one tap from Refresh.
- Skip detection is visible at last, per show and per episode. SkipSummary
is pure and its wording is tested, because the distinction that matters is
confirmed-and-acting versus still-gathering, and "not checked yet" is a
third state again -- midroll scanning runs nightly, so a fresh download
legitimately has nothing to say yet.
And the crash. Opening the new readout on a device threw
SQLiteBlobTooBigException, which turned out not to be the readout's fault:
show_fingerprints.pendingClipPcm held a whole decoded head/tail window --
240s at 8kHz is 3.84MB as 16-bit PCM -- and Android reads every Room row
through a CursorWindow that refuses anything over 2MB *on the way out*. The
insert succeeded and the next read threw. Intro/outro detection therefore
died the moment a show's second episode was fingerprinted and could never
have worked as shipped; nothing off-device could have caught it.
Migration 9->10 drops the column and keeps only pendingEpisodeId, re-decoding
on demand -- exactly what midrollPendingEpisodeId already did for the same
reason. StoredClip now owns the limit, MidrollUpdater caps a candidate
segment at 60s before persisting it (a long sponsor block would hit the same
wall) and refuses anything that still would not fit, and StoredClipSizeTest
pins both against the limit so the class cannot come back quietly. Trimming
under-skips a very long break rather than over-skipping into content, which
is the safe direction.
Verified on the emulator: 10 migration tests including the never-before-run
7->8, LibraryOrderTest, and the whole reported flow by hand -- bedtime split,
timer arming and cancelling, the dimmed row, both readouts. Re-ran the exact
sequence that crashed: three downloads, no crash, FingerprintWorker SUCCESS,
and the MP3 decoder visibly running, which is the first time
MediaCodecClipSource has executed on hardware at all.
Still open, and recorded in CLAUDE.md rather than glossed: detection has not
actually confirmed an intro or outro for a real show. Three Up First
downloads left both ends "Nothing found yet". Possibly correct -- the
threshold is strict by design -- or possibly the 8-second reference window
against a roughly 6-second sting. That wants the desktop prototype, not a
guess at a tuned constant.
Coverage 91.76% -> 91.84%.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Desktop-prototyped first, same discipline as the intro/outro half: three
Darknet Diaries episodes spanning 2026 and one from 2017 were fetched fresh
and cross-correlated. Beyond the show's fixed ~51s intro (present unchanged
across all nine years), the same coarse-to-fine scan found a second, shorter
segment recurring at a *different* timestamp in every episode's body and
independently present in the 2017 episode too -- a real recurring house
promo, not a coincidence of two fetches landing close in time.
That two-episode, different-position, still-present-nine-years-later shape
is exactly what a midroll break looks like and exactly what IntroOutroDetector
was never built to find: it only ever compares fixed head/tail windows.
Midroll detection means scanning a whole episode body, and a show can have
several distinct breaks at once, not one fixed slot per end.
- MidrollScanner: pure, single-resolution "find every segment two recordings
share, wherever each has it" -- deliberately position-agnostic, built on
the existing AudioCorrelation primitive rather than a new one.
- Decimate: block-averaging downsample, the coarse half of a coarse-to-fine
split. A brute-force native-rate scan across a full episode measured
30-160s per episode *pair* on a desktop CPU in the prototype; MidrollUpdater
runs MidrollScanner once on ~8x-decimated audio to find candidates cheaply,
then re-verifies each at full resolution with a single AudioCorrelation
call -- both for a trustworthy score and to reject anything the decimation
made look like a match but isn't.
- MidrollDetector: IntroOutroDetector's confirm/discard state machine,
generalised from one candidate per show to a list. A candidate an episode
doesn't confirm is discarded outright, same discipline, same reason: the
Darknet Diaries trap applies here too.
- MidrollUpdater: orchestrates discovery and confirmation per show. Discovery
compares the show's single rolling "pending" episode against whatever is
newest; unlike ShowFingerprintEntity's pendingClipPcm, no audio is stored
for it between runs -- a pending *whole episode* is tens of megabytes of
PCM, not the few seconds an intro/outro clip costs, so the file is
re-decoded from disk on demand and the pending slot just moves forward if
it's since been reclaimed by retention. A real bug the tests caught: the
same segment surfaces again every time the pending pointer rolls forward,
which without dedup became a second row for content already tracked.
Fixed by recognising a fresh discovery hit as already-tracked by *where*
it landed in the new episode (MidrollDetector.allMatches), not by
re-correlating extracted clip content, which two different coarse passes
don't reliably extract the same sub-range of.
- Storage: AppDatabase 7->8, a show_midrolls table (one row per candidate,
not per show -- several breaks can be tracked at once) and an
episode_midrolls table (the confirmed ranges detected per episode).
EpisodeEntity gains midrollCheckedAtMs (per-episode, not a show cursor, so
an out-of-order backfilled download still gets checked). SubscriptionEntity
gains midrollPendingEpisodeId.
- Playback: MidrollSkip mirrors AdChapters' shape exactly. PlayheadPolicy
checks it unconditionally, same as intro/outro -- no per-show toggle, the
confirmation threshold is the safety mechanism. PlaybackSources grows a
midrolls() method alongside chapters() rather than threading a new DAO
through PlaybackAdvancer's constructor, which would have pushed it over
detekt's LongParameterList threshold for no real reason.
- Trigger: MidrollWorker runs nightly while charging, not per-download like
FingerprintWorker -- a full-body scan is too expensive to run eagerly, and
unlike a download it never touches the network, so there is no wifi gate
either. Peter's call.
MediaCodecClipSource gains decodeFull, sizing the decode buffer from
durationMs with a margin rather than the fixed 4-minute clip window
decodeHead/decodeTail use.
Coverage 91.70% -> 91.76%. Neither half of Tier 3 has run on a device this
session -- see CLAUDE.md's known-gaps list.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sj3CoWEedM56bebByKgNxA
|