<feed xmlns='http://www.w3.org/2005/Atom'>
<title>listen.git/app/build.gradle.kts, branch main</title>
<subtitle>Listen — personal Android podcast app
</subtitle>
<id>https://git.terst.org/listen.git/atom?h=main</id>
<link rel='self' href='https://git.terst.org/listen.git/atom?h=main'/>
<link rel='alternate' type='text/html' href='https://git.terst.org/listen.git/'/>
<updated>2026-11-11T08:54:52+00:00</updated>
<entry>
<title>Shows have a category, not a tier</title>
<updated>2026-11-11T08:54:52+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-11-11T08:54:52+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/listen.git/commit/?id=aba4fb18ef8a542865e10a891081681352b64fcd'/>
<id>urn:sha1:aba4fb18ef8a542865e10a891081681352b64fcd</id>
<content type='text'>
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 -&gt; 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 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Delete on-device detection. Detection is the server's</title>
<updated>2026-11-04T10:14:52+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-11-04T10:14:52+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/listen.git/commit/?id=9678aa3e980d1e44defb3808812291968565f869'/>
<id>urn:sha1:9678aa3e980d1e44defb3808812291968565f869</id>
<content type='text'>
Peter's call: server first. The audio-fingerprint subsystem ran on the phone until detection moved
to the server, and then kept being shipped: FingerprintWorker and MidrollWorker went at the time
and the twelve classes they drove stayed, unreachable, in every APK since. ReachabilityTest found
it on 2026-11-03 once it was widened to classes, and it has been sitting in that test's allowlist
with its reasoning since.

Gone: IntroOutroDetector, AudioCorrelation, MidrollScanner, MidrollDetector, MidrollUpdater,
FingerprintUpdater, MediaCodecClipSource, Decimate, Fft, MonoResampler, PcmCodec, StoredClip, and
the 67 unit tests over them. 3,092 lines.

**Recoverable by name, not by archaeology.** The tag `on-device-detection` marks the last commit
that has it, pushed to the local remote along with everything else from this session, and the tag
message says how to bring it back. The work cost weeks and the approach may be wanted again.

What stayed, because none of it is detection: SkipSummary words the readout, BreakTrack and
BreakPopup draw and explain the timeline's marks, and MidrollClipStore shrank to deleteAllFor
alone -- its write and read halves had one caller each and both were the deleted updater, but a
phone that ran the old version still has clip files on disk and unsubscribing is the only thing
that clears them. Its test now writes the old layout by hand, because that layout is a
compatibility concern now rather than this class's own choice.

IntroOutroDetector.CONFIRM_THRESHOLD and MidrollDetector.CONFIRM_THRESHOLD moved to SkipSummary,
the only thing left that reads them. Both were three and the server's DefaultConfirmThreshold is
also three, checked against each other before the move.

**The database was deliberately not touched.** show_fingerprints, show_midrolls and
episode_midrolls are the server's columns now -- ServerSkips writes its answers into exactly the
columns the on-device detector used to, which is why playback needed no changes when detection
moved. Four MidrollDao methods went because they were the discovery half (upsertCandidate,
deleteCandidate, getUncheckedDownloaded, setMidrollCheckedAtMs) and the reachability check named
them; a DAO method is not schema, so there is no migration here and nothing for a real library to
go wrong over.

Two things found while doing it, neither caused by it, both written up in CLAUDE.md:

- **The per-show "Detected skips" readout can never say anything.** It reads candidate and
  confirmation rows, and nothing has written one since detection moved -- the server writes
  per-episode ranges and offsets instead. Verified on the emulator, not inferred: a fresh install
  with a live subscription shows "Nothing found yet / Nothing found yet / None found yet" while the
  server is finding and publishing breaks for that very show. It has been lying since the
  migration; deleting the detector only makes it undeniable.
- **RetentionSweeper's pending-episode guard protects nothing**, for the same reason: nothing sets
  a pending basis any more, so both id sets are always empty.

The APK barely moves -- 110KB of dex, 72.61MB to 72.50MB -- because it is dominated by the
embedded Python. This was never going to be a size win and is not being sold as one; it is 3,092
fewer lines to read, search and mislead.

1004 tests pass, detekt 0, lint clean. Coverage 95.35% -&gt; 94.58%, which is the documented
COVERAGE_EXCEPTION case: deleting dead code that happened to be well covered.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Look again for a waiting episode, and place a library nobody has placed</title>
<updated>2026-11-03T17:40:49+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-11-03T17:40:49+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/listen.git/commit/?id=ba6a03b2bd22ba29506e16caff708383caec07ac'/>
<id>urn:sha1:ba6a03b2bd22ba29506e16caff708383caec07ac</id>
<content type='text'>
The last two pieces, both of a kind: a feature that was built, wired and correct, and did nothing
because the thing that triggers it was missing.

**"Waiting for server" lasted until the next refresh.** The server starts fetching the moment it
is asked and its on-demand mirrors finish in seconds -- 183 of them over thirty days, several
completing two or three seconds apart. The phone looked again only on a feed refresh, which is the
5am alarm, a launch or the button, so an episode the server would have within the minute read
"waiting for server" for hours. That gap, not anything on the server, is what made "not checked
yet" a state a listener sees.

A wait now schedules one look back a minute later, through WaitingDownloads, which already bounds
the work two ways. KEEP rather than the REPLACE downloadNow uses: several episodes landing in the
waiting state one after another is the normal case, and REPLACE would push the retry a minute
further out each time until a queue of them starved it. The first wait sets the time.

The hook lives on ServerSkips rather than on a wrapper around it. The wrapper version was written
first and is the sort of thing that rots silently: changing the graph's declared type back to
ServerSkips drops the override, nothing fails, and the retry simply stops. WaitForServerWiringTest
now pins that a wait is reported through the real class, and was watched failing without it.

**Tiers were shipped empty.** The control, the push on every refresh, the server ordering its work
by it -- all built, all tested, and not one of 43 shows ever placed, because placing them was a
tapping job. So the server worked the library in feed order and the news waited behind whatever
else turned up, which is the exact thing the feature was asked for.

TierSeeder reads it off the feeds instead, checked against the real library before it was written.
It puts Tangle, Up First, Science Friday and Wards Way in Topical; both Dan Carlin shows, Radiolab,
Reply All and the bulk-published back catalogues in Whenever; and leaves the weekly middle -- 23 of
the 42 -- alone. Peter named Up First and Tangle as the ones to hurry and Dan Carlin and Radiolab
as the ones to let wait, so the rule agrees with the person it is for.

Publication rate alone does not work, which is why there is a floor. Serial, LOW and The Outer
Reach each measure a median gap near zero because a whole season went up at once: on rate they look
more topical than the news and mean the exact opposite. A gap under half a day is a back catalogue,
not a daily show.

It is a seed, not a policy. It runs once, touches only shows still sitting at the default, and
never a show placed by hand -- and it declines to spend its one run on a library with no episodes
to judge yet, or a fresh install would be marked done having placed nothing. Afterwards the control
in the show's settings is the only thing that decides.

Verified on the emulator rather than argued: Darknet Diaries, fortnightly, placed itself as
"Whenever" on a refresh with nothing tapped.

RetryWaitingWorker joins the three WorkManager entry points already excluded from coverage, under
the list's own rule -- the decisions are WaitingDownloads', and those are measured.

1071 tests, detekt 0, lint clean, coverage 95.29% -&gt; 95.35%.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Measure and test the view models the ui exclusion was hiding</title>
<updated>2026-11-03T08:29:32+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-11-03T08:29:32+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/listen.git/commit/?id=913ba0e829ca20ccbb1a8db213541760dfc42a4b'/>
<id>urn:sha1:913ba0e829ca20ccbb1a8db213541760dfc42a4b</id>
<content type='text'>
The coverage gate excluded `**/ui/**` wholesale, justified as "Compose screens are impractical to
unit-test". It also hid six view models -- plain Kotlin classes holding real decisions, every one
at 0%, not one of them a screen -- behind a justification that only ever applied to screens.
PlayerViewModel and ListenViewModel were 150 lines of untested logic the gate could not see.

Done by a subagent; its coverage figure, test count and detekt result were re-derived here from
the XML rather than taken on trust, and its two findings checked against the source.

The exclusion now names each screen file instead of the package. All six view models go from 0%
to 92-99%, the measured `ui` package to 96.6%, and the project to **95.17% against a 94.44%
baseline** -- the gate passes with the exclusion narrowed and no COVERAGE_EXCEPTION. 96 new tests,
suite 951 -&gt; 1048. The tests drive the real SubscriptionRepository, PlaybackAdvancer,
QueueRepository, DripReleaser and FeedResolver against the existing FakeDaos and MockWebServer, so
the banners asserted are the app's own wording rather than a stub agreeing with itself.

Four production seams, all behaviour-preserving: `UiGraph` and `ViewPreferences` over the graph,
`PlaybackControls` over PlayerConnection (the same shape as the `Playhead` precedent), and a
second constructor per view model with the `(Application)` one kept as a delegate. That last is
load-bearing in a way nothing would catch: `by viewModels()` finds a constructor reflectively, so
losing it throws on screen open with a green build and no failing test. ViewModelConstructionTest
is the guard. Its first version checked for the literal ": AndroidViewModel" and so missed
TranscriptViewModel, which wraps its supertype onto the next line -- a check that would have
passed while leaving a seventh view model unguarded in silence. It now cross-checks the two ways
of finding them against each other.

The action and state objects are excluded on a different ground from the screens, and the comment
says so explicitly so the list does not grow by analogy: they construct fine off a device, but
every line jacoco counts in them is one Kotlin generated -- constructor, componentN, copy, equals,
hashCode, toString. Measuring them measures the compiler. A data class that gains a method or an
init block comes back off the list.

Also, found while reviewing: detekt's `UnusedImports` rule is off by default, which is how 71 dead
imports accumulated across nine files. EpisodeActions.kt was eleven lines of data class under
forty of them -- Scaffold, SnackbarHost, LazyColumn and the rest, left behind when the screens
were split per file, which made it read like a screen. The rule is on and the 71 are gone. Two in
that file needed removing by hand: detekt whitelists `getValue`/`setValue` unconditionally because
it cannot tell a `by` delegation from none, and that file has no delegation at all.

1048 tests pass, detekt 0 findings on a fresh report, lint clean, coverage 95.17%.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Find why Tier 3 never confirmed, and port detection to Go</title>
<updated>2026-10-05T08:57:59+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-10-05T08:57:59+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/listen.git/commit/?id=7cc88394458e9d03e3a6a6e58cdff9c8dd4ad7a3'/>
<id>urn:sha1:7cc88394458e9d03e3a6a6e58cdff9c8dd4ad7a3</id>
<content type='text'>
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 -&gt; 99MB).

Full history and numbers in docs/detection-findings.md.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Correct durations from the file, and make the notification open the app</title>
<updated>2026-09-28T08:32:15+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-09-28T08:32:15+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/listen.git/commit/?id=c496ce8d8fb9d91d41cd544800a1f7fabb5d3ca1'/>
<id>urn:sha1:c496ce8d8fb9d91d41cd544800a1f7fabb5d3ca1</id>
<content type='text'>
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% -&gt; 92.96%.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Extend Tier 3 fingerprinting to midroll ad breaks</title>
<updated>2026-09-27T02:05:19+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-09-27T02:05:19+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/listen.git/commit/?id=850479ac3ffbff353c12faadd76f9ae840575136'/>
<id>urn:sha1:850479ac3ffbff353c12faadd76f9ae840575136</id>
<content type='text'>
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-&gt;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% -&gt; 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 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01Sj3CoWEedM56bebByKgNxA
</content>
</entry>
<entry>
<title>Tier 3 skip detection: audio fingerprinting, prototyped and built</title>
<updated>2026-09-26T22:57:26+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-09-26T22:57:26+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/listen.git/commit/?id=4e37185852ef3d1a2ed6348dd4c513648dafdd5d'/>
<id>urn:sha1:4e37185852ef3d1a2ed6348dd4c513648dafdd5d</id>
<content type='text'>
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-&gt;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% -&gt; 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-&gt;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 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01Sj3CoWEedM56bebByKgNxA
</content>
</entry>
<entry>
<title>Hide played episodes, and hand off to the video at the right moment</title>
<updated>2026-09-26T18:25:45+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-09-26T18:25:45+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/listen.git/commit/?id=06939defdbf144e630b5435bba552a730461440b'/>
<id>urn:sha1:06939defdbf144e630b5435bba552a730461440b</id>
<content type='text'>
Two of Peter's asks.

Played episodes are hidden by default. An episode you have heard is done with,
and leaving it in the list buries the ones that are not. A global toggle on the
show screen brings them back, so a mis-tapped "played" can still be found --
stored in DataStore, which has been a declared dependency since phase 1 with
nothing using it. Android Auto always hides them regardless of the toggle: a
car list is scrolled blind at a traffic light.

Hand-off carries the position both ways. Out: "Watch from here" opens the video
on its own site at the point reached. In: a link shared from YouTube with a
timestamp on it was shared at that moment deliberately, so the episode starts
there rather than at the top. WatchLink does both and is pure, because an
unrecognised URL shape fails silently -- as a link that simply starts from the
beginning -- and YouTube produces a lot of shapes.

The button sits on the episode screen as well as the player. That was not the
first design, and testing changed it: the player-only version is unavailable
exactly when it is most wanted, because an episode YouTube declines to serve
never starts playing and so never reaches the player at all.

Verified on a device, round trip: youtu.be/dQw4w9WgXcQ?t=90 shared in stored a
position of 90000, the episode screen offered Watch, and tapping it fired
youtube.com/watch?v=dQw4w9WgXcQ&amp;t=90s. Marking an episode played removed it
from the list, the toggle brought it back, and the preference survived a
restart. An ordinary podcast offers no hand-off, having no page to open.

Two things caught on the way. A crash: the episode cache was declared after the
init block that collects from a StateFlow, which emits synchronously, so it was
still null on the first emission -- caught by the launch check, now a plain
field declared with the others. And the played switch needed a contentDescription
distinct from its visible label, or it is ambiguous to pick -- the same trap the
drip switch hit.

Preferences joins the coverage exclusions: a Context, a file and a generated
Flow, deciding nothing. WatchLink is fully covered.

No permission changes, no schema change. 442 unit tests green, detekt and lint
clean, coverage steady at 90.13.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Skip marked breaks by default, and drop the instrumented-coverage half</title>
<updated>2026-09-26T17:37:33+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-09-26T17:37:33+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/listen.git/commit/?id=aa9c0a386f19bb58c50c81fd79d9509d2883cffe'/>
<id>urn:sha1:aa9c0a386f19bb58c50c81fd79d9509d2883cffe</id>
<content type='text'>
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 -&gt; 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 -&gt; 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 -&gt; 90.12.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
</feed>
