<feed xmlns='http://www.w3.org/2005/Atom'>
<title>listen.git/.githooks, 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-10-31T18:16:41+00:00</updated>
<entry>
<title>Gate the server too, and stop reading go test with grep</title>
<updated>2026-10-31T18:16:41+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-10-31T18:16:41+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/listen.git/commit/?id=3cba8487e76694da4f70bb4942faa7671d3e8b46'/>
<id>urn:sha1:3cba8487e76694da4f70bb4942faa7671d3e8b46</id>
<content type='text'>
The commit hook had nine steps and not one of them touched the server. Every
server change for a week -- twenty-five commits, including the ad detectors and
the thing that publishes skips -- went in on somebody remembering to type "go
test" and reading the output. That is exactly the habit the Gradle wrappers
exist to replace, in the half of the repo nobody had wrapped.

go-verify reports from exit codes and parsed `go test -json`: gofmt, build, vet
and a real test count. It was watched catching both failures that look like
success in a terminal -- a failing test, named; and a package that fails to
compile, where go test exits non-zero with no failing test and the count
silently drops from 366 to 356.

366 is a number I did not have until now, because "go test ./... | grep -v ok"
cannot produce one.

It runs first in the hook, because ten seconds before the Android build beats
several minutes after it.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Slow drip: work through an archive a few episodes at a time</title>
<updated>2026-09-25T17:09:13+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-09-25T17:09:13+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/listen.git/commit/?id=593ee7740de24aae22ddec1dc5a2e083fff06bd3'/>
<id>urn:sha1:593ee7740de24aae22ddec1dc5a2e083fff06bd3</id>
<content type='text'>
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 -&gt; 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 -&gt; 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 -&gt; 65.30.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Scaffold Listen, with a podcast-native feed parser</title>
<updated>2026-09-21T08:13:49+00:00</updated>
<author>
<name>Claude</name>
<email>noreply@anthropic.com</email>
</author>
<published>2026-09-21T08:13:49+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/listen.git/commit/?id=5876025559ba0ad287d3a7a4d9fa0246509e668e'/>
<id>urn:sha1:5876025559ba0ad287d3a7a4d9fa0246509e668e</id>
<content type='text'>
Sibling to FeeDreader, not a shared module. That was a considered call: its
FeedParser only inspects &lt;enclosure&gt; while hunting for a thumbnail, and its
FilterRuleEngine is 46 lines of title substring matching that doesn't
generalise to queue rules, so only Opml.kt would genuinely have transferred.

The parser is the piece everything else sits on, so it goes first, test-first:
18 tests covering audio/video enclosure selection, iTunes durations in both
second and clock form, season/episode numbers, podcast:chapters for the skip
feature, RFC 5005 archive paging for drip subscriptions, and episode identity
falling back to the enclosure URL when a guid is missing.

Two things worth recording:

- Built on XmlPullParserFactory rather than android.util.Xml. The latter
  returns null off-device, which is why FeeDreader's 297-line parser has no
  tests at all. Both resolve to the same KXmlParser on Android.
- SimpleDateFormat with isLenient=false rejects "Tue, 02 Sep 2026 06:00:00
  GMT" -- an entirely ordinary RFC 822 date. Left strict, every pubDate in
  the app would have been null and episode ordering would have been silently
  wrong everywhere.

minSdk is 29, deliberately not FeeDreader's 36, which would restrict the app
to Android 16 and newer.

Permissions: INTERNET and ACCESS_NETWORK_STATE for feeds; FOREGROUND_SERVICE,
FOREGROUND_SERVICE_MEDIA_PLAYBACK, POST_NOTIFICATIONS and WAKE_LOCK because
Android requires a foreground service with a notification to play audio in the
background. That notification is the transport control, not an alert -- Listen
posts no new-episode notifications by design.

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