From bf26b97b483bfba52d09adeb6516563830164f62 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 07:23:40 +0000 Subject: Play episodes whose enclosure is still cleartext http Reported from the phone: Show 62 of Hardcore History failed with ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED. Android has refused cleartext since API 28 and this app declares no exemption, so any enclosure published over http fails the moment it is streamed rather than played from a downloaded file. Dan Carlin's older episodes are published that way while the recent ones are not, which is why it went unnoticed; the manifest has not changed since long before the skip server, so this is not a regression from that work. Upgrading the scheme rather than permitting cleartext: that exact URL answers 200 over TLS and a range request returns 206 audio/mpeg, podcast CDNs serve the same path either way, and an exemption would weaken every other request the app makes for the sake of a few old enclosures. A host with genuinely no TLS is no worse off, since cleartext was refused already. Two paths, because they do not share a stack. Everything that goes through OkHttp -- downloads, feeds, chapters, artwork -- is covered by an interceptor on the one client the graph builds, which is the right home for a property of the app's networking rather than of any one feature. ExoPlayer has its own data source, so playback converts the URI as it builds the media item. Putting the conversion at the downloader's call site instead broke four of its MockWebServer tests, which was the tests making a fair point: a global network policy does not belong at one call site. Components under test build their own client and are untouched by the interceptor. Also stops the server starving on-demand episode requests. The poll loop and the request queue share a goroutine, so an episode the phone asked for waited out the whole cycle -- hours across 42 shows, which is why the tap that produced this report registered the episode and never fetched it. Now drained before each feed, bounding the wait at one feed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B --- server/cmd/listend/main.go | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) (limited to 'server') diff --git a/server/cmd/listend/main.go b/server/cmd/listend/main.go index 3e5c65a..4be69e2 100644 --- a/server/cmd/listend/main.go +++ b/server/cmd/listend/main.go @@ -182,6 +182,26 @@ func (w *worker) run(ctx context.Context, every time.Duration) { } } +// drainRequests serves everything the phone has asked for, without waiting. +// +// Bounds how long an on-demand episode waits at one feed rather than one cycle. It does not make +// the wait short -- a feed carrying detection takes minutes -- but the phone falls back to the +// publisher meanwhile, so the cost of waiting is skip offsets rather than playback. +func (w *worker) drainRequests(ctx context.Context) { + for { + if ctx.Err() != nil { + return + } + select { + case req := <-w.requests: + w.serveRequest(ctx, req) + w.save() + default: + return + } + } +} + func (w *worker) save() { if err := w.state.Save(w.statePath); err != nil { log.Printf("save: %v", err) @@ -199,6 +219,10 @@ func (w *worker) pollAll(ctx context.Context) { if ctx.Err() != nil { return } + // Before each feed, not only between cycles. The poll loop and the request queue share + // one goroutine, so an episode the phone asked for used to wait out the entire cycle -- + // hours across 42 shows, during which a tap on an unmirrored episode did nothing at all. + w.drainRequests(ctx) w.pollOne(ctx, feedURL) // After every feed rather than at the end of the cycle: a cycle over 61 feeds takes // hours, and a restart partway through used to discard all of it. -- cgit v1.2.3