| Age | Commit message (Collapse) | Author |
|
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
|
|
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
|
|
A second ad detector, and it finds a class the first structurally cannot.
The audio scan compares episodes mirrored within a minute of each other, so
what it finds is advertising a CDN stitched into all of them -- validated
against the declared-versus-served length gap. A show that inserts nothing has
no gap, which by that measure means it carries no advertising, and the host-read
sponsor block in the middle of it is invisible. Every published transcript the
server can use belongs to exactly such a show, because having no inserted
advertising is the condition for using one. The two detectors do not merely fail
differently; they look at disjoint sets of episodes.
**Density, not phrases.** One marker is a mention. Measured against the 38
transcripts harvested from the live library: a single "brought to you by" is a
one-second tag inside play-by-play and skipping it would cut the game, and a
guest describing how a sponsor came forward for his yacht event says "sponsor"
four times while being the programme -- the same trap the toc=false chapter rule
already documents. A real read stacks different *kinds* of marker inside a few
seconds: who it is for, where to go, what the offer is, what the terms are. Two
kinds and fifteen seconds, the same ad unit the length-gap rule uses.
Every threshold comes from a measurement rather than a guess. The 25-second
bridge is the 17.7-second gap between "presented by Delta" and "12% off" in the
Seahawks Rewind read, with the product description in between. "Slash" needs a
domain in front of it because the corpus contains "a children's movie slash book
for adults".
Across all 38 live transcripts it reports **one** candidate: that Delta block,
41:07 to 41:33, with no programme clipped at either end. That is low recall by
design -- these are the shows with the least advertising, because they are the
only ones whose transcripts can be used at all. The shows with real ad breaks
need speech recognition, which is not built.
**Nothing is published as a skip.** The candidates are stored with the words and
the lines are dimmed on the transcript screen, so the reader can see what the
server thinks is an advertisement and disagree with it. Dimmed rather than
hidden, because a wrong call has to stay visible. Nothing in the playback path
reads them -- same posture as the signature library's shadow run, and the same
reason: a detector acting on its own unchecked first guesses makes skips into
the middle of the programme, which is worse than no skips.
Schema 18 adds the mark to the cue, defaulting to not: a line nobody judged is
not an advertisement. Migrated and checked on a device.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
The ⋮ menu gains Transcript, which opens the episode's lines with the one being
spoken in bold and the list following the playhead. Tapping a line seeks to it,
which is the thing anybody actually wants a transcript for -- finding the bit
you half-heard.
It is also why the server refuses a publisher's transcript for an ad-inserted
copy. Tap a line in a transcript of the ad-free master and the audio starts
minutes from where the words are.
The words arrive with the audio rather than when the screen opens. The point of
downloading is that the episode works with no network, and a transcript fetched
on opening is blank on a plane or in a car park, which is where this app is used.
Tens of kilobytes beside tens of megabytes.
One row per cue, not a JSON column. Android reads every Room row through a
CursorWindow that refuses a row over 2MB *on the way out* -- the insert succeeds
and the next read throws -- which is exactly how the oversized pendingClipPcm
crashed the app on every show's second fingerprinted episode. A three-hour
episode is thousands of cues, so one column holding them all is that bug with a
different name. Both tables cascade from the episode. Schema 17, migrated and
checked on a device, including that deleting an episode takes its words.
Three states again, kept apart: words to read, a recorded reason there are none
-- the server looked at the publisher's transcript and would not use it for this
copy -- and nobody having looked. The menu offers the entry for the first two
and the screen says something different for each.
TranscriptSyncing holds the highlighting, apart from the screen and tested,
because the way this goes wrong is quiet: a highlight on the line before or
after the one being spoken looks right at a glance and is useless to read along
with. It holds the last line through a gap rather than flicking off during
silence or music, and highlights nothing before the first line, since an episode
opening on theme music has not said anything yet.
Verified end to end against a real feed before committing, on a throwaway data
directory: Wards Way Podcast mirrored, both episodes' transcripts taken, and the
API served 748 cues whose last one ends 466ms before the measured file does.
Transcripts live on their own view model rather than two more functions on
ListenViewModel, which was already at the size the linter allows -- same call as
splitting Screens.kt, and they have a different lifetime anyway.
The coverage gate caught the replace transaction having no direct test, which
was fair: its clear-then-insert ordering is the thing that would leave a reader
looking at an empty list under a header counting lines.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
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
|
|
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
|
|
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
|
|
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 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
|
|
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
|
|
Closes the last deferred Phase 2 item. Desktop-prototyped first, per the
design plan's own instruction, against three real shows (see the prototype
findings recorded in memory): NPR Up First (a clean ~6s intro sting, r
0.96-0.998 across every pair tested), Tangle (a long 60-90s open/sponsor
read, equally reliable), and Darknet Diaries (no real intro -- a segment
that matched between two temporally-close episodes turned out to be a
dynamically-inserted ad, confirmed by checking a 2018 episode that matched
the same "intro" despite the multi-year gap).
That last finding drove the actual design: IntroOutroDetector requires 3+
independently-fetched episodes to agree before trusting a candidate and
auto-applying it, which is exactly what the Darknet Diaries ad would not
have survived. The confirmation threshold is the safety mechanism, not
just caution for its own sake -- and it's now a parameter, not a hardcoded
assumption, after coverage review found the "still tentative" branch was
unreachable at the original threshold.
- AudioCorrelation: FFT-based normalized cross-correlation, a Kotlin port
of the validated prototype. Tested against numpy/scipy-derived ground
truth fixtures, not just self-consistency -- the prototype's own
normalization bug (global mean instead of each window's local mean,
producing a bogus 0.98 "match" plain correlation put at -0.02) is exactly
what that guards against being ported here unnoticed.
- IntroOutroDetector: the pure state machine (waiting/tentative/confirmed/
discard-on-mismatch), mirroring DripSchedule's shape.
- MediaCodecClipSource: the one Android-only piece (MediaExtractor/
MediaCodec decode to mono 8kHz PCM), on the framework-adapter coverage
exclusion list for the same reason PlayerConnection is.
- Storage: AppDatabase 6->7, a new show_fingerprints table (one row per
show per intro/outro, tracking the current candidate clip and how many
episodes have confirmed it) and two new nullable EpisodeEntity columns.
Tier 2's manual per-show offset remains the fallback -- PlaybackProgress
itself is unchanged; DetectedSkip.kt is the entire integration surface.
- Trigger: EpisodeDownloader gained an onDownloaded hook (every download
route ends up there, manual or queued) firing a new one-shot
FingerprintWorker per episode.
Coverage improved (90.13% -> 91.70%): the new pure logic is fully tested,
and closing two real gaps the coverage review surfaced (an untested
first-two-episodes-disagree path, and ShowFingerprintEntity's hand-written
equals/hashCode, needed because a ByteArray property breaks the generated
one) accounted for more than the new Android-glue lines cost.
Not yet verified on a device -- no phone connected this session. The
MigrationTest 6->7 case and the actual decode/match pipeline both need a
real run before this is trusted with real playback.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sj3CoWEedM56bebByKgNxA
|
|
Two things.
Ad-chapter skipping is on by default, at Peter's request after living with it.
It shipped off out of caution, being the one part of the skip feature that
removes audio nobody has heard -- but it only ever acts on sections a publisher
explicitly marked as not content, never on a guess from a title, and doing that
without being asked each time is the point. Migration 5 -> 6 moves existing
shows rather than leaving the new default to apply only to new ones.
The column's SQL default stays 0 while the Kotlin default becomes true. Room
writes every column on insert, so the SQL default is only ever consulted by an
ALTER TABLE that has already happened; changing it would mean recreating the
table for a value nothing reads. Said so at the field.
Two unit tests were leaning on the old default to mean "not opted in". They now
say `skipAdChapters = false` explicitly, which is what they meant.
Second: enableAndroidTestCoverage is gone again, a day after adding it. It was
the "both" half of the coverage change, and measuring it made the trade
obvious -- it instruments every class in the debug build, which cost 25MB on
the APK (118MB -> 143MB) plus runtime probes, on every sideload, for a report
that would be run rarely. I had already published one of those builds. The
framework-adapter exclusions carry this on their own; they are what moved the
number from 65% to 90%, and what makes a future drop mean something.
No permission changes. 428 unit tests green, 6 migration tests on a device,
detekt and lint clean, coverage 89.97 -> 90.12.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Two of Peter's asks.
Ordering. A show ranked by its newest unplayed episode, so queueing something
left the show sitting at the top with nothing actually waiting -- his words,
once they're on the queue they're noise in the list. Queued episodes are now
excluded from the ranking for the same reason played ones already were: they
have been dealt with.
That is one clause of SQL, and the fake DAOs return insertion order, so it is
exactly the kind of rule that cannot be pinned off-device. LibraryOrderTest
runs against a real in-memory Room database and covers the change, an older
unqueued episode still counting, played episodes still not counting, and a show
with nothing waiting sorting last rather than vanishing. Confirmed it fails
without the fix before keeping it.
Filters. Peter chose the existing rule engine over anything new, with the rules
set up for him -- and rules live in the database on the phone, so a migration is
the only way to put them there. MIGRATION_4_5 seeds "trailer" and "preview",
both mark-played, as ordinary rules: they show up in the Rules screen and can be
edited, disabled or deleted. Appended after whatever is already there rather
than taking position 0, so they cannot pre-empt rules already relied on, and
INSERT OR IGNORE on a fixed id means deleting one does not bring it back on the
next upgrade. They act only on newly discovered episodes, so nothing already in
the library changes.
One judgement call worth flagging: "preview" will catch a sports show's
legitimate Season preview. It is two taps to delete if it bites.
Schema 4 -> 5, no shape change -- the migration only inserts rows. Covered by
two instrumented tests: seeding after an existing rule, and a 1 -> 5 chain
landing exactly two.
No permission changes. 403 unit tests green, 10 instrumented, detekt and lint
clean. Coverage 65.20 -> 64.90, exception recorded: the new code is a migration
tested on a device, and the coverage report counts unit tests only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
The last of Phase 2's three tracks, and the part Peter asked for in place of the
audio fingerprinting, which stays deferred.
Two tiers. Manual per-show offsets start each fresh episode past the intro and
treat it as finished before the outro; resuming something part-heard still lands
where it was left, and an offset longer than the episode is dropped rather than
obeyed, because someone typing minutes where seconds were meant should not have
a show start at the end. The fields have been on SubscriptionEntity since phase
1 with nothing reading them.
Chapters are the exact tier: where a show publishes a Podcasting 2.0 chapter
file, the player lists it and taps seek. Breaks the publisher marked `toc:
false` can be skipped, opt-in per show. Nothing is guessed from chapter titles
-- a show discussing its own sponsorship deal has a chapter called "Sponsors"
that is the actual programme, and a skip feature that eats an episode is worse
than none. A break whose end cannot be worked out plays rather than throwing the
playhead somewhere arbitrary.
Two real bugs surfaced while verifying this on a device, both older than this
work:
A start position handed to `seekTo` after `prepare()` is silently dropped across
the MediaController-to-service boundary. The intro offset began at zero, and so,
all along, did every resume -- an episode left half-heard restarted from the
top. Nothing failed and nothing logged. It now goes to `setMediaItem`, verified
on a device both ways: a fresh episode starts at the offset, a stored position
resumes at 150s and plays on from there.
Finishing an episode did not stop playback when the queue was empty, because
until now the file had always ended anyway. With an outro offset the episode
finishes while audio is still playing, so it marked the episode played and then
played the credits regardless.
Schema v3 -> v4: the chapter file cached per episode, and the per-show opt-in.
The migration test now covers 1 -> 4 straight through and asserts the opt-in
arrives off -- defaulting it on would have started removing audio from every
show at once.
Per-show settings moved to their own view model. Drip, the archive fetch and
both skip settings are one concern used by one screen, and ListenViewModel had
become the entry point for the library, the queue, episode actions and these as
well. It also gets its own busy flag: fetching an archive and refreshing a feed
are different operations, and one spinner for both had the top bar claiming a
refresh was running when it was not.
Verified on a device against a real feed throughout: a 60-second intro offset
starts the episode at 60s, an outro offset finishes it early and stops the
audio, and a seeded chapter file with a five-second marked break plays 0-5s then
jumps to 45s with the chapter list rendering alongside.
No permission changes. 364 unit tests green, detekt and lint clean, coverage
65.30 -> 66.23.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
The second of Phase 2's three tracks. A show can be set to release its back
catalogue on a cadence -- one a day, oldest first, by default -- and while drip
is on the show lists only what has been released rather than the raw feed. A 600
episode archive then reads as a show you are keeping up with instead of a wall.
A release always enqueues, and deliberately does not go through the rule engine.
Drip decides when you see an episode and the rules decide what happens to one;
running a released episode through the rules as well would let a rule silently
cancel a release, which reads as the drip being broken.
Three pieces of arithmetic worth keeping, all in DripSchedule and all tested:
The clock advances by whole intervals rather than to now. Moving it to now
discards the part-interval already elapsed, and a six-hourly worker against a
daily drip loses hours every day until "one a day" means one every day and a
bit.
A long absence is capped at ten and the excess is forgiven rather than carried.
A month away owes thirty episodes; handing those over empties the archive into
the queue, which is the thing the feature exists to avoid. Carrying the debt
instead would just pay it out as a capped burst on each of the next three runs.
Switching drip on settles what it already owes: everything played or already
queued is released up front, because hiding episodes you have heard makes a show
followed for a year look like it has three, and hiding one mid-queue strands it
where nothing can reach it. Re-enabling starts the cadence from now rather than
paying off a debt accrued while it was off.
Visibility is a query parameter, not stored state, so switching drip off
restores the whole catalogue without touching a row. Android Auto browses the
same set. Feed reconciliation deliberately still sees everything -- matching
against the visible set alone would read a held-back episode as missing and
re-insert it on every refresh.
RFC 5005 archive paging is what gives a drip anything to drip: FeedArchiveWalker
follows prev-archive pages, resolving relative hrefs against the page that
carried them, and stops on a loop, a page cap, or a fetch failure -- keeping what
it collected and reporting why, because half an archive is useful and a silent
half surfaces months later as "why did this show run out?".
Two gaps found while wiring it up, both fixed here: the subscription screen had
no snackbar at all, so a refresh started from it reported nothing and an archive
fetch would have been silent; and both toggles were bare Switches with no
accessible name, which TalkBack cannot announce and the UI checker cannot tap.
Schema v2 -> v3. The four non-null drip columns carry SQL defaults because
SQLite cannot add a NOT NULL column without one, declared on the entity too so
the migration and the exported schema describe the same table. Verified twice:
the migration test now covers 1 -> 3 straight through, and a real v2 database
seeded with a subscription, an episode at position 424242 and a rule was
upgraded in place on a device with all three surviving and drip defaulting off.
OPML moved to its own view model rather than growing ListenViewModel past its
function limit again -- it shares nothing with the library, is used by one
screen, and the settings screen now depends on it alone.
The exported-schema trap from the last commit happened again, after I had
written the warning down. Step 9 of the pre-commit hook now refuses a commit
that modifies an already-committed schema JSON, since a note was demonstrably
not enough.
No permission changes. 333 unit tests green, detekt and lint clean, coverage
63.63 -> 65.30.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
The first of Phase 2's three tracks. A rule is a set of conditions on a newly
discovered episode -- show, title contains/excludes, duration range, age, season
-- and an action: queue at the top or bottom, mark played, and optionally
download it or pin it to a speed. Rules are ordered, individually switchable,
and the first one that matches claims the episode.
Every claimed episode records which rule fired and the account that rule gave,
shown on the episode screen as `Handled by "News to the queue" - title contains
"news"`. That is not decoration. Automation that fills a queue and cannot say
why is the thing that makes people stop trusting it, so the reason string is
tested as hard as the matching is.
Three decisions worth keeping:
Rules never fire on a first subscribe. That import discovers the entire back
catalogue -- 2,753 episodes on a real feed -- and a catch-all rule would enqueue
all of it. `applyEpisodes` takes a `report` flag and `subscribe` passes false.
Verified live: subscribing to NPR News Now stored four episodes and queued none;
deleting the newest and refreshing queued exactly that one, with its reason
recorded.
A rule's speed lands on the episode, not the show. Writing it to
`SubscriptionEntity.playbackSpeed` would let one matching episode silently reset
the speed of everything else the show publishes.
Auto-downloads go through a new DownloadWorker rather than the refresh. A
refresh should finish in seconds whatever the rules decided; a worker that spent
twenty minutes pulling audio would be killed mid-flight with the feed state half
written. The drainer caps a batch at ten and leaves a failure FAILED rather than
retrying forever against a 404.
Schema v1 -> v2, this app's first migration, with MIGRATION_1_2 validated on a
device against the exported v1 schema and a seeded playback position read back
afterwards. Two pieces of plumbing were needed and both fail obscurely: the
schemas directory wired into androidTest assets, and kotlinx-serialization
raised to 1.8.1 because Room 2.8's bundle parser calls a method 1.7.3 lacks and
dies with AbstractMethodError at runtime. Both are written up in CLAUDE.md,
along with the trap that cost the most time -- changing an entity before bumping
the version rewrites the exported schema for the old version, destroying the
only record of what is on the phone.
No permission changes. 298 unit tests green, detekt and lint clean, coverage
61.60 -> 63.63.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Episode identity is the part worth care. Publishers re-host and regenerate
guids, and the naive result is a duplicate episode and a listener who has lost
their place in a three-hour recording. EpisodeMatcher falls back guid ->
enclosure URL -> title+date, claims each stored episode at most once, and
carries position, played state and download state across untouched. Title
alone never matches, since a weekly show reuses one.
Refresh sends stored ETag/Last-Modified so the common case -- nothing new --
is a bodyless 304 rather than re-downloading every feed on the phone. A 304 is
a distinct outcome, not an empty feed, and absence from a feed page is never
treated as deletion: feeds are routinely truncated.
Failures are recorded on the subscription, not logged. A feed that has quietly
stopped working is otherwise indistinguishable from one with no new episodes,
which is the failure mode that has bitten every project in this workspace.
Note on process: SubscriptionRepository was written before its tests, against
the rule in CLAUDE.md, and all 11 passed first run -- exactly the symptom that
rule exists to catch. Rather than assume they were meaningful I mutation-tested
them: removing the failure recording is caught by two tests, and misreporting a
304 as an update is caught by one. A third mutation went uncaught but turned
out to be inert, since the matcher has no delete path at all.
No destructive migration fallback. Playback positions and play history are not
reproducible from anywhere, so a missing migration must fail loudly rather than
silently wipe the library.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|