<feed xmlns='http://www.w3.org/2005/Atom'>
<title>listen.git/docs, 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>Keep what the listener asked for, and publish ads only from a publisher's master</title>
<updated>2026-11-03T08:22:11+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-11-03T08:22:11+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/listen.git/commit/?id=87b8cfa942b995d1c013827ec7014cf6ef4138ca'/>
<id>urn:sha1:87b8cfa942b995d1c013827ec7014cf6ef4138ca</id>
<content type='text'>
Two changes to the server, both measured against the live data rather than reasoned about.

**Subtraction publishes only from a publisher's transcript.** Subtracting a publisher's transcript
recovers 94-104% of the advertising the file lengths imply; subtracting a refetch -- a second
download of the same assembled file, carrying its own ads in its own places -- recovers a median of
33%. That tier had already been tried and rejected at 21% and 0%, but nothing in the publish path
was told which master it had, so it reached the phone anyway on seventeen ad spans.

Written as a denylist first, and measuring it against a copy of the live data showed why that was
the wrong shape: sixteen episodes carry a master whose label is blank -- the field postdates the
first masters written, and Reclaim restored those files after the library was destroyed with the
blank intact -- and they published on the strength of not being the one named bad kind. They
happened to be publisher transcripts, so the answer was right by luck. It is an allowlist now, and
the blank labels are repaired from the master files, which have said "published" all along.

Measured on a copy, not predicted:

  before   49 ad spans (17 refetch, 24 unlabelled, 8 published) + 604 cue, 245 episodes
  after    62 ad spans (all publisher-master)                   + 611 cue, 245 episodes

No episode loses its skips; the refetch ones trade advertising nobody could measure for the show's
own recurring audio, which is a preference rather than an unconditional skip. Measuring also caught
the recurring pass blanking the refusal subtraction had just written -- both passes answer "why is
nothing published" through one field and recurring runs second, so all thirty-two episodes that now
carry a reason were losing it a moment later.

**Being asked for keeps an episode mirrored.** Retention was publication date alone, and pruning
calls words.Forget, which deletes the transcript *file*. So an episode outside the newest eight was
fetched on demand, recognised, served, and deleted on the next poll -- and asking again paid the
whole cost over. From the server's own journal over twenty-one days: 397 transcription runs
covering 309 distinct episodes, with 7.7 of the 33.4 hours of whisper time spent on episodes it had
already recognised and thrown away. 23% of the budget, re-doing work it had discarded.

Working through a back catalogue -- the archive tier, the slow-drip shows, the listening this was
built for -- therefore paid full recognition for every episode and again to resume one. The window
still decides what is worth fetching; RequestedAtUnix now decides what is worth keeping, stamped on
every /api/episode call including for episodes already mirrored, because resuming something played
last month must not let it fall out mid-listen. Kept for 28 days after the last ask, the same span
refetchAfterSeconds uses, being the same kind of judgement about how long something stays
interesting.

Disk points the same way: transcripts are 36MB for 445 files, audio is 17GB for 336 episodes, and
the volume has 365GB free. The expensive artefact is the small one.

374 server tests pass. Not yet deployed; the phone's half of this -- a pending reply is retried
only on a feed refresh -- is still to come.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Write down what the corpus actually says, and correct three stale claims</title>
<updated>2026-11-02T07:51:10+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-11-02T07:51:10+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/listen.git/commit/?id=5fc97b66bda97d3af14da6d3de69e84f29299773'/>
<id>urn:sha1:5fc97b66bda97d3af14da6d3de69e84f29299773</id>
<content type='text'>
The findings doc's last entry was 2026-10-19 and ended by naming what was missing: "a mechanism
that can tell a real short ad from a short coincidence". Words are that mechanism, it was built,
and thirty-two server commits later the doc never recorded what it measured. A future session
would have re-attempted the ad/furniture classifier that was already measured and deleted.

The new entry is derived from the live state file rather than quoted, which is how it caught that
publish.go's own figure describes a subset. "92% to 104% across eighteen episodes" is the
publisher-transcript number. Across the 24 episodes now publishing an ad span, recovery runs 20%
to 146%. Split by where the master came from, the spread stops being a spread: a publisher's
transcript gives 94-104%, a refetch gives a median of 33%. Nothing in Publishable looks at which
it has, and refetch masters are now fifteen of those twenty-four -- the majority of a tier the
design had decided not to act on.

It also caught me getting a conclusion wrong. Two Reply All episodes publish ad spans at 146% and
138% of their length gap, which reads as the detector over-claiming by half a minute of
programme. Looking at the show instead of the episode: Reply All's gaps run from -0.48 to +1.02
minutes across eight episodes. They straddle zero, so the feed's declared durations are not a
baseline for that show and the thing being exceeded is noise. The structural finding is the useful
one -- HasInsertedAdvertising asks only whether this episode's gap clears one ad unit, and has no
notion of whether the show's gaps are consistent enough to mean anything. Both versions are in the
entry, because the wrong one is the one a reader would reach for next time.

Nothing here changes behaviour. Whether a refetch should publish an ad at all is Peter's call and
is written up as such.

CLAUDE.md, separately:

- It still said the episode description shows about two lines. That was changed to ten yesterday,
  for a reason that is recorded: the limit existed to stop long notes pushing the transport
  off-screen, and the merge had already put the notes below the transport. Left as it was, the
  doc was an instruction to reintroduce the bug.
- "207 unit tests" has been wrong for most of the project's life; the suite is past nine hundred.
  Replaced with a note to ask the wrappers, since any number written here goes stale the same way.
- The TDD rule banned backfilling tests onto existing code without qualification, which also
  forbids the one case where it is right -- pinning behaviour before moving it, so a refactor is
  provably behaviour-preserving. Narrowed to say that, and to say the order matters. The sixteen
  formatter tests in 2cc252a were written that way.
- Added the rule those formatter files were moved to satisfy: anything the app says out loud lives
  in a named file of its own and is tested, never inside the composable that renders it. It was
  already followed by eight files and stated only in WaitingForServerText's own comment.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Plan: the labels are wired end to end, and when they are not applied</title>
<updated>2026-10-27T08:16:33+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-10-27T08:16:33+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/listen.git/commit/?id=114c15b27b01c47de212e280923bc6d35b8dfa4f'/>
<id>urn:sha1:114c15b27b01c47de212e280923bc6d35b8dfa4f</id>
<content type='text'>
Verified on the live server: a mark vouching for a real detected break
reaches the signature that break produced and sets confirmed on it.
The probe and the flag it set were removed afterwards; that corpus
holds the listener's statements, not test data.

Also records the property that cost two runs to notice -- a label is
only actionable while its episode is still mirrored. They outlive the
audio deliberately, so the server holds plenty naming episodes long
rotated out, and those are simply not applied.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Use the labels: seed signatures from them, and take them back</title>
<updated>2026-10-27T07:54:25+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-10-27T07:54:25+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/listen.git/commit/?id=50b52d58e9865ac01e5364ec68247edb323db9b7'/>
<id>urn:sha1:50b52d58e9865ac01e5364ec68247edb323db9b7</id>
<content type='text'>
The labels have been arriving and sitting there. Now the shadow run
acts on all three kinds.

A report is advertising pointed at where detection found nothing, so
it becomes a signature in its own right -- that being the whole gap
the library exists to close, since the scan accounts for about 38% of
what the feeds imply is there.

A mark saying a break is right adds no audio, because the break is
harvested anyway. What it adds is that a person vouched for it.

A mark saying a break is wrong does two things. It stops that audio
being harvested, and it takes back any signature already held that
matches it. That second half is the one that matters: a signature
built from programme would cut the programme in every episode carrying
it, until somebody noticed. So his "no" removes it outright rather
than lowering a score.

Being pointed at outranks partners and occasions both. Those are ways
of inferring what a stretch of audio is; he was there. A confirmed
signature is trusted at once and stays confirmed -- he does not
un-hear it next week.

A report whose end was never corrected is trimmed to its first fifteen
seconds. An uncorrected end is wherever the 25-second jump landed
rather than where the advertising stopped, so its tail may be
programme -- and one ad unit is already far more audio than a landmark
fingerprint needs to be distinctive.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Start testing the Compose screens</title>
<updated>2026-10-27T07:46:46+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-10-27T07:46:46+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/listen.git/commit/?id=aa046a7ee11a20914a5851dd0e5a89d129683b28'/>
<id>urn:sha1:aa046a7ee11a20914a5851dd0e5a89d129683b28</id>
<content type='text'>
The dependencies for this were already in the build -- ui-test-junit4
on androidTest, ui-test-manifest on debug -- and had never been used.
So the spike was one test, and it passed first run. Eighteen now.

What they cover is the data-driven composables, chosen because a
regression in them looks completely normal: a readout that quietly
stops printing one of its three numbers, a control that stops
reporting whether it will play or pause, a bar that stops naming what
is playing while a screen shows something else.

The episode transport: the ad button is offered only while something
is playing, because with nothing playing there is no advertising to be
in the middle of and an enabled button would record a claim about a
position nobody is at. The primary control says what pressing it will
do. The skip buttons move by the amount they advertise.

The skip readout: all three numbers on one line, the status when there
is nothing to cut -- "nothing found" and "not checked yet" being
different answers is the mistake the old readout made -- and a legend
that names only what is actually on the track.

The now-playing bar: names the episode and the show, opens what is
playing when tapped, and reports play against pause.

Screens that need view models are still verified by eye. This is the
half that did not need them.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Write down a refused token instead of only remembering it</title>
<updated>2026-10-27T07:39:19+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-10-27T07:39:19+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/listen.git/commit/?id=ec633d0daf17ab5806c563b7a95d6721b7b73c7b'/>
<id>urn:sha1:ec633d0daf17ab5806c563b7a95d6721b7b73c7b</id>
<content type='text'>
401 and 403 are permanent by decision -- a refused token cannot fix
itself -- and the notice saying so was held in memory. So it vanished
with the process and only came back once the next refresh had gone and
been refused all over again, which for 61 feeds is hours of the app
quietly downloading from publishers with nothing on screen to say why.

It now lives in the preferences file and is there at launch.

Saving different credentials clears it at once rather than waiting for
a request to prove it, because that is the one act that answers the
complaint. Saving the same ones again proves nothing and leaves the
notice standing.

Behind a small interface so SkipServer does not have to know whether
it is writing to a preferences file or to nothing: every test wants it
in memory, without a Context to hang a DataStore off.

The stored one takes the reading and the writing as two narrow seams
rather than a Preferences. Passing Preferences dragged a Context in
and made it the one piece of the server client that could only be
exercised on a device -- the coverage gate caught that, which is what
it is for. What it actually decides, when *not* to write, is now plain
Kotlin with five tests on it: clearing runs after every successful
reply, so two hundred successes in a sweep must write nothing.

Verified on a device: a refusal written by one Preferences instance is
read back by another, and four cases around saving credentials behave
as described.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Plan: step 4 of the UI redesign is done</title>
<updated>2026-10-25T07:26:58+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-10-25T07:26:58+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/listen.git/commit/?id=85aa11207fb64a86eddd0591ab5a189d22433f7e'/>
<id>urn:sha1:85aa11207fb64a86eddd0591ab5a189d22433f7e</id>
<content type='text'>
Records what moved and why, including the two controls that left
Settings -- speed because the episode screen already had it, the sleep
timer because it is an action rather than a setting.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Plan: mark the library built and the shadow run gathering</title>
<updated>2026-10-24T09:27:51+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-10-24T09:27:51+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/listen.git/commit/?id=cd497f8972ab9a5225813f2106f89f255a291c35'/>
<id>urn:sha1:cd497f8972ab9a5225813f2106f89f255a291c35</id>
<content type='text'>
Four and five are done and six is deliberately not. Everything six
needs exists; what is missing is weeks of real batches, and building
it now would throw away the only reason the shadow run exists.

Records what to watch for in the log, and the one decision the tests
forced: a copy that arrives with partners is trusted at once, because
holding it back would mean only ever proposing advertising detection
had already found twice. A cue gets no shortcut, which is the
independence intro and outro are waiting for.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Send the listener's labels to the server and keep them</title>
<updated>2026-10-24T09:01:05+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-10-24T09:01:05+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/listen.git/commit/?id=34e50c04b18313d92e3b3a565965675fd8b50b8a'/>
<id>urn:sha1:34e50c04b18313d92e3b3a565965675fd8b50b8a</id>
<content type='text'>
The phone has had two kinds of label and nowhere to put them: a mark,
which is a judgement on a break detection found, and a report, which
is the notification button pointing at advertising it missed. They
catch opposite errors, and the signature library needs both.

POST /api/labels now takes them and state.json keeps them. Episodes
are named by feed and guid rather than by either side's own id -- the
phone never sees the server's, and the server's belongs to a mirror
that forgets an episode after eight. The labels sit beside the shows
rather than on them for the same reason: the corpus has to outlive the
audio it was given on.

A report's end is sent as guessed or measured. It is only measured
once the listener came back and trimmed it; otherwise it is wherever
the jump landed, and a signature cut to that would carry programme.

The phone re-sends everything each sweep and keeps no record of what
arrived. The server replaces rather than accumulating, so a re-send is
free, there is no sync state to get wrong, and a label lost to a crash
comes back on the next refresh. Gated on audioFromServer, like every
other server re-query: a label names a position in a particular file,
and dynamic ad insertion means the publisher's copy is a different one.

Accepting a label writes the state file immediately. Everything else
is written at the end of a poll cycle, which for 61 feeds is hours,
and a tap made once should not wait that long to become durable.

Deployed and checked against the live server: a label is accepted, a
re-send leaves one, an unauthenticated POST gets 401, and the label is
on disk within seconds. The probe label used for that check was
removed afterwards.

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