diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-10-14 17:30:36 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-10-14 17:30:36 +0000 |
| commit | 1e05c7bbf5e2789201e6f6d48fbac78784c3125f (patch) | |
| tree | 2cd37712aee9530a4cc9bcacf6258f541b5989c8 /app | |
| parent | 061e1f8b7a0eb3d182fb63b716e583d2df25bfc4 (diff) | |
Fix whole-branch review findings in break classification and furniture toggle
Six fixes surfaced only by a whole-branch view of the kind/furniture work:
symmetric served-vs-declared comparison in Classify so a short-served
episode (e.g. 30:00 declared, 29:30 served) can no longer read a real
pre-roll as the show's own intro; a runCatching guard around the DataStore
read backing skipFurniture() so a corrupted preferences file can no longer
kill the media service at episode start; a proper isAdvertising predicate
on SkipSummary so the episode screen's ad/furniture colour split no longer
depends on comparing against a renamable display string; a stale comment
in PlayheadPolicy describing the pre-toggle behaviour; the already-unreachable
servedMs<=0 guard in furnitureKindAt now fails safe to KindAd instead of
KindCue; and an ORDER BY on getRanges so kind-filtered lookups are
deterministic. Throughout: anything unknown, empty or unjudgeable behaves
as advertising, never as furniture.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
Diffstat (limited to 'app')
7 files changed, 64 insertions, 8 deletions
diff --git a/app/src/main/java/org/terst/listen/data/Daos.kt b/app/src/main/java/org/terst/listen/data/Daos.kt index 8dbaa32..b971aac 100644 --- a/app/src/main/java/org/terst/listen/data/Daos.kt +++ b/app/src/main/java/org/terst/listen/data/Daos.kt @@ -380,7 +380,10 @@ interface MidrollDao { @Query("DELETE FROM show_midrolls WHERE subscriptionId = :subscriptionId AND id = :id") suspend fun deleteCandidate(subscriptionId: String, id: String) - @Query("SELECT * FROM episode_midrolls WHERE episodeId = :episodeId") + // Ordered by start: MidrollSkip.skipTarget filters by kind before firstOrNull, so an + // unordered result could let a short cue nested inside an ad pod win over the pod and + // produce a 5-second seek instead of the full jump. + @Query("SELECT * FROM episode_midrolls WHERE episodeId = :episodeId ORDER BY startMs") suspend fun getRanges(episodeId: String): List<EpisodeMidrollEntity> @Insert(onConflict = OnConflictStrategy.REPLACE) diff --git a/app/src/main/java/org/terst/listen/data/Repositories.kt b/app/src/main/java/org/terst/listen/data/Repositories.kt index e09f34e..46731c8 100644 --- a/app/src/main/java/org/terst/listen/data/Repositories.kt +++ b/app/src/main/java/org/terst/listen/data/Repositories.kt @@ -95,7 +95,15 @@ class AppGraph(private val context: Context) { override suspend fun midrolls(episode: EpisodeEntity) = midrolls.getRanges(episode.id) override suspend fun fetchAudio(episode: EpisodeEntity, kind: SubscriptionKind) = downloader.download(episode, kind).map { } - override suspend fun skipFurniture() = preferences.skipShowFurniture.first() + // DataStore's flow can throw (CorruptionException, IOException) if the preferences + // file is damaged, and this runs on PlaybackService's bare scope.launch with no + // CoroutineExceptionHandler -- unguarded, that kills the media service process the + // moment an episode starts. Default to true (furniture skipped) rather than false: + // that is today's behaviour, and it is the fail-safe direction -- a break that + // silently stops being skipped is the failure mode that matters here, so losing the + // preference must not quietly let a show's own audio start playing again. + override suspend fun skipFurniture() = + runCatching { preferences.skipShowFurniture.first() }.getOrDefault(true) }) } diff --git a/app/src/main/java/org/terst/listen/fingerprint/SkipSummary.kt b/app/src/main/java/org/terst/listen/fingerprint/SkipSummary.kt index 47bb248..71907bd 100644 --- a/app/src/main/java/org/terst/listen/fingerprint/SkipSummary.kt +++ b/app/src/main/java/org/terst/listen/fingerprint/SkipSummary.kt @@ -96,6 +96,20 @@ object SkipSummary { else -> "Ad break" } + /** + * Whether a break is advertising, as its own behavioural predicate rather than a comparison + * against [describeKind]'s display string. + * + * Reuses the exact rule [org.terst.listen.playback.MidrollSkip] already encodes for what + * actually gets skipped unconditionally: anything that is not the show's own recurring audio + * (intro, cue, outro) counts as advertising, including a kind this version does not + * recognise and an empty string. The episode screen's ad/furniture colour split calls this + * instead of comparing against the English label, because renaming that label used to invert + * the colours for every band -- and since describeKind's else branch returns "Ad break" for + * anything unrecognised, renaming the ad label would have made every band draw as furniture. + */ + fun isAdvertising(kind: String): Boolean = kind !in setOf("intro", "cue", "outro") + /** One end of a show's episodes -- its intro or its outro. */ fun candidateState(state: ShowFingerprintEntity?, threshold: Int): String { // No reference clip means no candidate: either nothing has been processed, or a single diff --git a/app/src/main/java/org/terst/listen/playback/PlayheadPolicy.kt b/app/src/main/java/org/terst/listen/playback/PlayheadPolicy.kt index a1dafac..02b2768 100644 --- a/app/src/main/java/org/terst/listen/playback/PlayheadPolicy.kt +++ b/app/src/main/java/org/terst/listen/playback/PlayheadPolicy.kt @@ -31,8 +31,11 @@ object PlayheadPolicy { return TickAction.Finish } - // No per-show toggle, same as intro/outro: the confirmation threshold behind a detected - // midroll break is the safety mechanism, not an extra switch nobody would think to flip. + // Advertising is skipped unconditionally; the show's own audio (intro, cue, outro) is + // skipped only when context.skipFurniture says to -- the global preference read at + // episode start. There is still no *per-show* toggle: the confirmation threshold behind + // a detected midroll break is the safety mechanism for whether a break is trusted at + // all, and the furniture preference is the only switch on top of that. MidrollSkip.skipTarget(context.midrolls, positionMs, context.skipFurniture) ?.let { return TickAction.SeekTo(it) } diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt b/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt index 9baff97..70f4a77 100644 --- a/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt +++ b/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt @@ -295,9 +295,10 @@ private fun EpisodeScrubber(playback: EpisodePlayback, actions: EpisodeDetailAct /** * The detected breaks as bands across the track, advertising in one colour and the show's own - * audio (intro, cue, outro) in another -- the same distinction [SkipSummary.describeKind] names - * in words, so a kind this version does not recognise draws as advertising too, matching what - * playback does with it. + * audio (intro, cue, outro) in another, decided by [SkipSummary.isAdvertising] rather than by + * comparing against [SkipSummary.describeKind]'s display string -- a renamed label must not + * silently invert which colour means advertising. A kind this version does not recognise draws + * as advertising too, matching what playback does with it. */ @Composable private fun BreakMarks(breaks: List<SkipSummary.BreakMarker>, modifier: Modifier) { @@ -312,7 +313,7 @@ private fun BreakMarks(breaks: List<SkipSummary.BreakMarker>, modifier: Modifier breaks.forEach { mark -> val left = inset + usable * mark.start val right = inset + usable * mark.end - val isAd = SkipSummary.describeKind(mark.kind) == "Ad break" + val isAd = SkipSummary.isAdvertising(mark.kind) drawRect( color = if (isAd) adColour else furnitureColour, topLeft = Offset(left, 0f), diff --git a/app/src/test/java/org/terst/listen/ReachabilityTest.kt b/app/src/test/java/org/terst/listen/ReachabilityTest.kt index da7d9a3..ec603fa 100644 --- a/app/src/test/java/org/terst/listen/ReachabilityTest.kt +++ b/app/src/test/java/org/terst/listen/ReachabilityTest.kt @@ -27,6 +27,14 @@ class ReachabilityTest { "YouTubeAudio.ytDlpVersion" to "Called from YouTubeAudioTest, which fails on a stale bundled yt-dlp. That guard " + "is the whole point; it has no job in production.", + "SkipSummary.describeKind" to + "Only called from SkipSummary.breakLines, in the same file, since the episode " + + "screen's break-list labels and its ad/furniture colour split (FIX 3) were " + + "deliberately decoupled -- the colour split now uses isAdvertising, not this " + + "display string. This heuristic only credits a cross-file call as a caller, so " + + "it cannot see that breakLines is reached from EpisodeScreen.kt via " + + "SkipSummary.episode(); the function is genuinely live, just one hop further " + + "than the check looks.", ) private val mainDir = File("src/main/java/org/terst/listen") diff --git a/app/src/test/java/org/terst/listen/fingerprint/SkipSummaryTest.kt b/app/src/test/java/org/terst/listen/fingerprint/SkipSummaryTest.kt index b1decbd..367bf04 100644 --- a/app/src/test/java/org/terst/listen/fingerprint/SkipSummaryTest.kt +++ b/app/src/test/java/org/terst/listen/fingerprint/SkipSummaryTest.kt @@ -390,6 +390,25 @@ class SkipSummaryTest { assertEquals("Ad break", SkipSummary.describeKind("sponsorship")) } + /** + * [isAdvertising] is the behavioural predicate the episode screen's colour split must use + * instead of comparing against [describeKind]'s English copy -- the bug it fixes: renaming + * "Ad break" would otherwise invert the colours for every band, because describeKind's else + * branch would still return the renamed string for nothing while every real "ad" stopped + * matching it. Includes the unknown-kind and empty-string cases, which both have to read as + * advertising -- the same rule [org.terst.listen.playback.MidrollSkip] already encodes for + * what actually gets skipped. + */ + @Test + fun `isAdvertising matches the kinds that are actually skipped unconditionally`() { + assertEquals(true, SkipSummary.isAdvertising("ad")) + assertEquals(false, SkipSummary.isAdvertising("intro")) + assertEquals(false, SkipSummary.isAdvertising("cue")) + assertEquals(false, SkipSummary.isAdvertising("outro")) + assertEquals(true, SkipSummary.isAdvertising("sponsorship")) + assertEquals(true, SkipSummary.isAdvertising("")) + } + @Test fun `break markers carry the kind so the scrubber can distinguish them`() { val ranges = listOf( |
