summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-10-14 17:30:36 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-10-14 17:30:36 +0000
commit1e05c7bbf5e2789201e6f6d48fbac78784c3125f (patch)
tree2cd37712aee9530a4cc9bcacf6258f541b5989c8 /app
parent061e1f8b7a0eb3d182fb63b716e583d2df25bfc4 (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')
-rw-r--r--app/src/main/java/org/terst/listen/data/Daos.kt5
-rw-r--r--app/src/main/java/org/terst/listen/data/Repositories.kt10
-rw-r--r--app/src/main/java/org/terst/listen/fingerprint/SkipSummary.kt14
-rw-r--r--app/src/main/java/org/terst/listen/playback/PlayheadPolicy.kt7
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt9
-rw-r--r--app/src/test/java/org/terst/listen/ReachabilityTest.kt8
-rw-r--r--app/src/test/java/org/terst/listen/fingerprint/SkipSummaryTest.kt19
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(