summaryrefslogtreecommitdiff
AgeCommit message (Collapse)Author
2026-09-21Episode downloads, with the YouTube path kept separateClaude
Two sources with no fallback between them. A YouTube subscription's media URL is a watch page, not audio: fetching it over plain HTTP would store an HTML document under an .mp3 name that fails to play with nothing to explain why. The subscription kind decides the route. Audio is written to a .part name and renamed only once complete. A truncated file recorded as DOWNLOADED plays as a cut-off episode and reads like the publisher's fault. On failure the partial is removed and localPath cleared, so a FAILED row never points at a file something downstream might try to play. A download recorded as complete whose file has since been deleted is fetched again rather than trusted, because otherwise playback fails on a file the database insists exists. File names are derived from the episode id with separators stripped. Ids come from feeds, and a guid containing a path separator would otherwise write outside the library directory. The Chaquopy-backed source sits behind an interface so the downloader's own logic stays testable off-device; nothing in the real source can run without an Android Python runtime. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-21Playback: position persistence, completion rules, sleep timerClaude
Where a listener got to is the only data here that cannot be reconstructed from anywhere, so the rules around it are pure and tested rather than tangled into Media3 callbacks. PositionWriter depends on a narrow Playhead interface rather than Media3's Player, which is far too large to fake. That was driven by the coverage gate rejecting the first version: the component holding the app's only irreplaceable data was reachable only from an emulator. Testing it caught a real bug -- switching episodes carried the previous episode's write threshold across, so a new episode's early position looked like a tiny move and went unrecorded. Completion is not a percentage. A test caught that too: 98% of a four-hour recording still has five minutes of content, so the threshold now only applies when what remains is plausibly credits rather than programme. A stored position beyond the end restarts instead of seeking past the end and stalling, which is what happens after a publisher re-encodes an episode. Position is written on an interval, immediately on any backward seek, and flushed on task-removal and destroy. Android kills a backgrounded media process without warning, so waiting for a clean stop loses it. The sleep timer fades rather than cuts. Extending adds to the time remaining rather than resetting: "+5 minutes" with 30 seconds left means five and a half, and silently dropping those 30 seconds is bad in a control used half-asleep. My first implementation had this backwards; the test caught it. Audio focus ducks rather than pauses -- spoken word survives ducking for a navigation prompt far better than a pause mid-sentence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-21Subscription resolution, OPML, queue ordering, retentionClaude
Discovery was cut, so the share sheet and OPML are the only ways to subscribe. That raises the bar on the resolver: anything that fails to resolve is a show the user cannot add at all. It handles a direct feed, a show page via its link rel=alternate, an Apple Podcasts link via its numeric id, and a YouTube channel or playlist via the RSS endpoint YouTube already publishes -- which means a channel becomes an ordinary subscription and only the download step is special. Handle (@name) URLs carry no channel id and return null rather than being guessed at. OPML is ported from FeeDreader but onto XmlPullParserFactory, so unlike the original it is actually testable off-device. Folder nesting is read and recorded even though Listen has no folders: silently discarding structure the user organised is worse than ignoring it. Queue ordering is pure list operations. Re-adding a queued episode moves it rather than duplicating it, play-next on the episode already playing is a no-op instead of interrupting it, and an out-of-range drag index is ignored rather than thrown. Retention keeps an episode when its position is non-zero even though it is marked played -- that combination means it was resumed, and the audio is in use. Disabled is a flag, not an enormous grace period, because playedAt + Long.MAX_VALUE overflows negative and would delete everything. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-21Data layer: schema, episode matching, conditional-GET refreshClaude
Episode identity is the part worth care. Publishers re-host and regenerate guids, and the naive result is a duplicate episode and a listener who has lost their place in a three-hour recording. EpisodeMatcher falls back guid -> enclosure URL -> title+date, claims each stored episode at most once, and carries position, played state and download state across untouched. Title alone never matches, since a weekly show reuses one. Refresh sends stored ETag/Last-Modified so the common case -- nothing new -- is a bodyless 304 rather than re-downloading every feed on the phone. A 304 is a distinct outcome, not an empty feed, and absence from a feed page is never treated as deletion: feeds are routinely truncated. Failures are recorded on the subscription, not logged. A feed that has quietly stopped working is otherwise indistinguishable from one with no new episodes, which is the failure mode that has bitten every project in this workspace. Note on process: SubscriptionRepository was written before its tests, against the rule in CLAUDE.md, and all 11 passed first run -- exactly the symptom that rule exists to catch. Rather than assume they were meaningful I mutation-tested them: removing the failure recording is caught by two tests, and misreporting a 304 as an update is caught by one. A third mutation went uncaught but turned out to be inert, since the matcher has no delete path at all. No destructive migration fallback. Playback positions and play history are not reproducible from anywhere, so a missing migration must fail loudly rather than silently wipe the library. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-21Merge in-app yt-dlp: verified working end to end, Termux no longer neededPeter Stone
2026-09-21Embed yt-dlp via Chaquopy instead of shelling out to TermuxClaude
Verified end to end on an emulator: the probe resolves a real video and the download lands 54,006,240 bytes of m4a, matching the size the probe predicted. Two traps found by running it rather than reasoning about it: - Chaquopy defaults to CPython 3.8, which yt-dlp no longer supports, so pip silently resolves 2024.10.22 -- the last release that did, nearly two years stale, and YouTube extraction does not work with it. The build succeeds and the feature is dead. Pinned to 3.12, which gets 2026.8.19, and YouTubeAudioTest now fails on any pre-2025 yt-dlp so it cannot regress quietly. - Chaquopy 16.1.0 calls org.gradle.util.VersionNumber, removed in Gradle 9, so Gradle is pinned to 8.14.3 here while FeeDreader stays on 9.5. AGP stays at 8.13.2. Chaquopy 17 requires AGP 9.x, so moving off this means bumping all three together. yt-dlp's JSON is parsed through a separate, unit-tested mapping rather than inline in the Chaquopy call. It is a separate project on its own release cadence and is the boundary most likely to drift, so a field going missing or changing type should fail a test rather than crash on the phone. Parsed with kotlinx.serialization because org.json is an empty stub off device, which would have made the mapping untestable. Audio-only by design: separate streams need ffmpeg to merge, the documented yt-dlp failure mode on Android, and a pre-muxed audio stream has nothing to merge. No ffmpeg dependency at all. No proof-of-origin token support, deliberately -- obtaining one means running Google's attestation script in a WebView, a different act from downloading, and it is not needed from an ordinary connection. Known risk recorded in CLAUDE.md: yt-dlp has deprecated YouTube extraction without a JavaScript runtime, and there is no deno or node on Android. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-21Scaffold Listen, with a podcast-native feed parserClaude
Sibling to FeeDreader, not a shared module. That was a considered call: its FeedParser only inspects <enclosure> 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B