<feed xmlns='http://www.w3.org/2005/Atom'>
<title>listen.git/app/schemas, 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>Shows get a priority, so the news is not stuck behind a back catalogue</title>
<updated>2026-10-31T08:39:44+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-10-31T08:39:44+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/listen.git/commit/?id=cb3377b4856e3419df1cbc52044eda8bf68a006d'/>
<id>urn:sha1:cb3377b4856e3419df1cbc52044eda8bf68a006d</id>
<content type='text'>
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 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>A break says which detector found it, and the episode screen says so</title>
<updated>2026-10-31T07:03:03+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-10-31T07:03:03+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/listen.git/commit/?id=cbcd04cfb3c11ae0a171f4fce1954214baeff074'/>
<id>urn:sha1:cbcd04cfb3c11ae0a171f4fce1954214baeff074</id>
<content type='text'>
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 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Read the advertising out of an episode's words</title>
<updated>2026-10-28T08:57:32+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-10-28T08:57:32+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/listen.git/commit/?id=cbf6fb8c5f3d5edb4ce8bcd89666070d0c35c49b'/>
<id>urn:sha1:cbf6fb8c5f3d5edb4ce8bcd89666070d0c35c49b</id>
<content type='text'>
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 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>The app reads an episode's words, and follows them while they play</title>
<updated>2026-10-28T08:41:34+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-10-28T08:41:34+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/listen.git/commit/?id=562742d040d75bdb2875e7411622dc3ec952ed24'/>
<id>urn:sha1:562742d040d75bdb2875e7411622dc3ec952ed24</id>
<content type='text'>
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 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Add a "this is an ad" button to the notification</title>
<updated>2026-10-20T08:04:06+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-10-20T08:04:06+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/listen.git/commit/?id=37136b152e923f784ad4056f9deddfab1d7f1e8c'/>
<id>urn:sha1:37136b152e923f784ad4056f9deddfab1d7f1e8c</id>
<content type='text'>
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 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Let the listener validate or invalidate a detected break from a popup</title>
<updated>2026-10-20T06:48:48+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-10-20T06:48:48+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/listen.git/commit/?id=9cc92e743dda2609a67595439c4694f6a4efae27'/>
<id>urn:sha1:9cc92e743dda2609a67595439c4694f6a4efae27</id>
<content type='text'>
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 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Refresh skips on feed refresh, without stamping server offsets onto publisher audio</title>
<updated>2026-10-16T08:11:41+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-10-16T08:11:41+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/listen.git/commit/?id=6c4d40ef9b0556a9071a3cfbcfa58878433e8996'/>
<id>urn:sha1:6c4d40ef9b0556a9071a3cfbcfa58878433e8996</id>
<content type='text'>
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-&gt;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 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Give a stored break its kind</title>
<updated>2026-10-14T08:21:59+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-10-14T08:21:59+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/listen.git/commit/?id=b1d8647f5dc655faffd8b95a7a96b78c83eeffea'/>
<id>urn:sha1:b1d8647f5dc655faffd8b95a7a96b78c83eeffea</id>
<content type='text'>
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 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Start shared timestamps playing, and stop the feed clobbering real lengths</title>
<updated>2026-09-29T06:26:00+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-09-29T06:26:00+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/listen.git/commit/?id=16a47088a315786a940590794d7ce4003447cf7c'/>
<id>urn:sha1:16a47088a315786a940590794d7ce4003447cf7c</id>
<content type='text'>
**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% -&gt; 93.02%.

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