diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-09-26 16:05:19 -1000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-09-26 16:05:19 -1000 |
| commit | 850479ac3ffbff353c12faadd76f9ae840575136 (patch) | |
| tree | 0cce18ac0e4691828eb8fcd066f10ea9b18aeac6 | |
| parent | 4e37185852ef3d1a2ed6348dd4c513648dafdd5d (diff) | |
Extend Tier 3 fingerprinting to midroll ad breaks
Desktop-prototyped first, same discipline as the intro/outro half: three
Darknet Diaries episodes spanning 2026 and one from 2017 were fetched fresh
and cross-correlated. Beyond the show's fixed ~51s intro (present unchanged
across all nine years), the same coarse-to-fine scan found a second, shorter
segment recurring at a *different* timestamp in every episode's body and
independently present in the 2017 episode too -- a real recurring house
promo, not a coincidence of two fetches landing close in time.
That two-episode, different-position, still-present-nine-years-later shape
is exactly what a midroll break looks like and exactly what IntroOutroDetector
was never built to find: it only ever compares fixed head/tail windows.
Midroll detection means scanning a whole episode body, and a show can have
several distinct breaks at once, not one fixed slot per end.
- MidrollScanner: pure, single-resolution "find every segment two recordings
share, wherever each has it" -- deliberately position-agnostic, built on
the existing AudioCorrelation primitive rather than a new one.
- Decimate: block-averaging downsample, the coarse half of a coarse-to-fine
split. A brute-force native-rate scan across a full episode measured
30-160s per episode *pair* on a desktop CPU in the prototype; MidrollUpdater
runs MidrollScanner once on ~8x-decimated audio to find candidates cheaply,
then re-verifies each at full resolution with a single AudioCorrelation
call -- both for a trustworthy score and to reject anything the decimation
made look like a match but isn't.
- MidrollDetector: IntroOutroDetector's confirm/discard state machine,
generalised from one candidate per show to a list. A candidate an episode
doesn't confirm is discarded outright, same discipline, same reason: the
Darknet Diaries trap applies here too.
- MidrollUpdater: orchestrates discovery and confirmation per show. Discovery
compares the show's single rolling "pending" episode against whatever is
newest; unlike ShowFingerprintEntity's pendingClipPcm, no audio is stored
for it between runs -- a pending *whole episode* is tens of megabytes of
PCM, not the few seconds an intro/outro clip costs, so the file is
re-decoded from disk on demand and the pending slot just moves forward if
it's since been reclaimed by retention. A real bug the tests caught: the
same segment surfaces again every time the pending pointer rolls forward,
which without dedup became a second row for content already tracked.
Fixed by recognising a fresh discovery hit as already-tracked by *where*
it landed in the new episode (MidrollDetector.allMatches), not by
re-correlating extracted clip content, which two different coarse passes
don't reliably extract the same sub-range of.
- Storage: AppDatabase 7->8, a show_midrolls table (one row per candidate,
not per show -- several breaks can be tracked at once) and an
episode_midrolls table (the confirmed ranges detected per episode).
EpisodeEntity gains midrollCheckedAtMs (per-episode, not a show cursor, so
an out-of-order backfilled download still gets checked). SubscriptionEntity
gains midrollPendingEpisodeId.
- Playback: MidrollSkip mirrors AdChapters' shape exactly. PlayheadPolicy
checks it unconditionally, same as intro/outro -- no per-show toggle, the
confirmation threshold is the safety mechanism. PlaybackSources grows a
midrolls() method alongside chapters() rather than threading a new DAO
through PlaybackAdvancer's constructor, which would have pushed it over
detekt's LongParameterList threshold for no real reason.
- Trigger: MidrollWorker runs nightly while charging, not per-download like
FingerprintWorker -- a full-body scan is too expensive to run eagerly, and
unlike a download it never touches the network, so there is no wifi gate
either. Peter's call.
MediaCodecClipSource gains decodeFull, sizing the decode buffer from
durationMs with a margin rather than the fixed 4-minute clip window
decodeHead/decodeTail use.
Coverage 91.70% -> 91.76%. Neither half of Tier 3 has run on a device this
session -- see CLAUDE.md's known-gaps list.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sj3CoWEedM56bebByKgNxA
28 files changed, 2055 insertions, 25 deletions
diff --git a/.coverage-baseline b/.coverage-baseline index 944bcd0..19e5c5d 100644 --- a/.coverage-baseline +++ b/.coverage-baseline @@ -1 +1 @@ -91.70 +91.76 @@ -127,9 +127,27 @@ downloaded at once, Facebook videos are supported, the library ranks shows by th and opt-in skipping of breaks the publisher marked `toc: false`. Nothing is guessed from chapter titles. -**Tier 3 skip detection — audio fingerprinting of theme music — is still not built.** Peter -deferred it; the design plan puts it in Phase 3 and wants a desktop prototype against real -episodes before any Android code. +**Tier 3 skip detection — audio fingerprinting — is built, in two parts.** Both were desktop- +prototyped against real episodes before any Android code, per the design plan's own instruction. + +- **Intro/outro** (`IntroOutroDetector`, `AudioCorrelation`, `MediaCodecClipSource`, + `FingerprintUpdater`): one fixed candidate per show per end, needing 3+ independently-fetched + episodes to agree before it is trusted -- the confirmation threshold exists because two episodes + fetched close together can agree on a segment that is a dynamically-inserted ad, not the show's + real intro (found prototyping against Darknet Diaries). Triggered per download via + `FingerprintWorker`. +- **Midroll ad breaks** (`MidrollScanner`, `MidrollDetector`, `MidrollUpdater`): a show can have + several distinct midroll breaks, at a different timestamp in every episode, so this scans a + whole episode body rather than a fixed head/tail window -- `MidrollScanner.findSharedSegments` + finds every segment two episodes share, at whatever position each happens to have it, and + `MidrollUpdater` composes a coarse (decimated) pass with a native-resolution verify so that scan + is fast enough to run on a phone. Prototyping this against Darknet Diaries found a real recurring + segment (not the fixed intro) independently present across episodes nine years apart. Runs + nightly while charging (`MidrollWorker`), not per-download: a full-body scan is too expensive for + that, and unlike a download it never touches the network. No per-show toggle, same as + intro/outro -- the confirmation threshold is the safety mechanism. + +Neither has been verified on a device yet -- see the known-gaps list below. **ID3 `CHAP` frames are dropped, not deferred** — decided 2026-09-26 by measuring Peter's actual 61 subscriptions rather than guessing. Every feed's newest episode was fetched far enough to read @@ -161,6 +179,11 @@ by default and correct; it is simply waiting for a publisher who marks breaks. - **Android Auto is unverified in a real car.** The session protocol is proven by `MediaBrowseTest`; whether Auto's own compatibility checks pass on a head unit is untested, and a sideloaded media app may need Auto's developer mode enabled. +- **Neither half of Tier 3 fingerprinting has run on a device.** The decode/decimate/correlate + pipeline is proven against synthetic fixtures and, separately, against real downloaded episodes + in a throwaway desktop prototype -- never against `MediaCodecClipSource`'s actual + MediaExtractor/MediaCodec decode loop on real hardware, and the `MigrationTest` 7→8 case has + only run on the JVM-backed `MigrationTestHelper`, not an emulator or device. ## Domain notes worth keeping diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f76f892..811d044 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -290,6 +290,7 @@ val coverageExclusions = listOf( // this is the one class between them that actually touches the Android media stack. "**/fingerprint/MediaCodecClipSource*", "**/work/FingerprintWorker*", + "**/work/MidrollWorker*", ) tasks.register<JacocoReport>("jacocoTestReport") { diff --git a/app/schemas/org.terst.listen.data.AppDatabase/8.json b/app/schemas/org.terst.listen.data.AppDatabase/8.json new file mode 100644 index 0000000..2bb5564 --- /dev/null +++ b/app/schemas/org.terst.listen.data.AppDatabase/8.json @@ -0,0 +1,745 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "5e0e6b89ad8f015fabecd87a83e4de70", + "entities": [ + { + "tableName": "subscriptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `feedUrl` TEXT NOT NULL, `kind` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT, `author` TEXT, `imageUrl` TEXT, `link` TEXT, `addedAtMs` INTEGER NOT NULL, `etag` TEXT, `lastModified` TEXT, `lastFetchedAtMs` INTEGER, `lastError` TEXT, `playbackSpeed` REAL, `skipIntroMs` INTEGER NOT NULL, `skipOutroMs` INTEGER NOT NULL, `dripEnabled` INTEGER NOT NULL DEFAULT 0, `dripIntervalMs` INTEGER NOT NULL DEFAULT 86400000, `dripCount` INTEGER NOT NULL DEFAULT 1, `dripOldestFirst` INTEGER NOT NULL DEFAULT 1, `dripLastReleaseAtMs` INTEGER, `skipAdChapters` INTEGER NOT NULL DEFAULT 0, `midrollPendingEpisodeId` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "feedUrl", + "columnName": "feedUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT" + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "link", + "columnName": "link", + "affinity": "TEXT" + }, + { + "fieldPath": "addedAtMs", + "columnName": "addedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT" + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "TEXT" + }, + { + "fieldPath": "lastFetchedAtMs", + "columnName": "lastFetchedAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastError", + "columnName": "lastError", + "affinity": "TEXT" + }, + { + "fieldPath": "playbackSpeed", + "columnName": "playbackSpeed", + "affinity": "REAL" + }, + { + "fieldPath": "skipIntroMs", + "columnName": "skipIntroMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "skipOutroMs", + "columnName": "skipOutroMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dripEnabled", + "columnName": "dripEnabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "dripIntervalMs", + "columnName": "dripIntervalMs", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "86400000" + }, + { + "fieldPath": "dripCount", + "columnName": "dripCount", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "dripOldestFirst", + "columnName": "dripOldestFirst", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "dripLastReleaseAtMs", + "columnName": "dripLastReleaseAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "skipAdChapters", + "columnName": "skipAdChapters", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "midrollPendingEpisodeId", + "columnName": "midrollPendingEpisodeId", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_subscriptions_feedUrl", + "unique": true, + "columnNames": [ + "feedUrl" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_subscriptions_feedUrl` ON `${TABLE_NAME}` (`feedUrl`)" + } + ] + }, + { + "tableName": "episodes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `subscriptionId` TEXT NOT NULL, `guid` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT, `mediaUrl` TEXT NOT NULL, `mediaType` TEXT NOT NULL, `mediaLengthBytes` INTEGER, `durationMs` INTEGER, `publishedAtMs` INTEGER, `seasonNumber` INTEGER, `episodeNumber` INTEGER, `imageUrl` TEXT, `chaptersUrl` TEXT, `discoveredAtMs` INTEGER NOT NULL, `positionMs` INTEGER NOT NULL, `playedAtMs` INTEGER, `downloadState` TEXT NOT NULL, `localPath` TEXT, `downloadedAtMs` INTEGER, `dripReleasedAtMs` INTEGER, `appliedRuleId` TEXT, `appliedRuleReason` TEXT, `speedOverride` REAL, `midrollCheckedAtMs` INTEGER, `chaptersJson` TEXT, `detectedIntroMs` INTEGER, `detectedOutroMs` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`subscriptionId`) REFERENCES `subscriptions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subscriptionId", + "columnName": "subscriptionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "guid", + "columnName": "guid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "mediaUrl", + "columnName": "mediaUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaType", + "columnName": "mediaType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mediaLengthBytes", + "columnName": "mediaLengthBytes", + "affinity": "INTEGER" + }, + { + "fieldPath": "durationMs", + "columnName": "durationMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "publishedAtMs", + "columnName": "publishedAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "seasonNumber", + "columnName": "seasonNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "episodeNumber", + "columnName": "episodeNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "chaptersUrl", + "columnName": "chaptersUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "discoveredAtMs", + "columnName": "discoveredAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "positionMs", + "columnName": "positionMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playedAtMs", + "columnName": "playedAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "downloadState", + "columnName": "downloadState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localPath", + "columnName": "localPath", + "affinity": "TEXT" + }, + { + "fieldPath": "downloadedAtMs", + "columnName": "downloadedAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "dripReleasedAtMs", + "columnName": "dripReleasedAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "appliedRuleId", + "columnName": "appliedRuleId", + "affinity": "TEXT" + }, + { + "fieldPath": "appliedRuleReason", + "columnName": "appliedRuleReason", + "affinity": "TEXT" + }, + { + "fieldPath": "speedOverride", + "columnName": "speedOverride", + "affinity": "REAL" + }, + { + "fieldPath": "midrollCheckedAtMs", + "columnName": "midrollCheckedAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "chaptersJson", + "columnName": "chaptersJson", + "affinity": "TEXT" + }, + { + "fieldPath": "detectedIntroMs", + "columnName": "detectedIntroMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "detectedOutroMs", + "columnName": "detectedOutroMs", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_episodes_subscriptionId", + "unique": false, + "columnNames": [ + "subscriptionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_episodes_subscriptionId` ON `${TABLE_NAME}` (`subscriptionId`)" + }, + { + "name": "index_episodes_subscriptionId_guid", + "unique": true, + "columnNames": [ + "subscriptionId", + "guid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_episodes_subscriptionId_guid` ON `${TABLE_NAME}` (`subscriptionId`, `guid`)" + }, + { + "name": "index_episodes_publishedAtMs", + "unique": false, + "columnNames": [ + "publishedAtMs" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_episodes_publishedAtMs` ON `${TABLE_NAME}` (`publishedAtMs`)" + } + ], + "foreignKeys": [ + { + "table": "subscriptions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "subscriptionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "queue", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`episodeId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`episodeId`), FOREIGN KEY(`episodeId`) REFERENCES `episodes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "episodeId", + "columnName": "episodeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "episodeId" + ] + }, + "indices": [ + { + "name": "index_queue_position", + "unique": false, + "columnNames": [ + "position" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_queue_position` ON `${TABLE_NAME}` (`position`)" + } + ], + "foreignKeys": [ + { + "table": "episodes", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "episodeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `position` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `subscriptionId` TEXT, `titleContains` TEXT, `titleExcludes` TEXT, `minDurationMs` INTEGER, `maxDurationMs` INTEGER, `maxAgeMs` INTEGER, `seasonNumber` INTEGER, `action` TEXT NOT NULL, `autoDownload` INTEGER NOT NULL, `speed` REAL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscriptionId", + "columnName": "subscriptionId", + "affinity": "TEXT" + }, + { + "fieldPath": "titleContains", + "columnName": "titleContains", + "affinity": "TEXT" + }, + { + "fieldPath": "titleExcludes", + "columnName": "titleExcludes", + "affinity": "TEXT" + }, + { + "fieldPath": "minDurationMs", + "columnName": "minDurationMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxDurationMs", + "columnName": "maxDurationMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxAgeMs", + "columnName": "maxAgeMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "seasonNumber", + "columnName": "seasonNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "autoDownload", + "columnName": "autoDownload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "speed", + "columnName": "speed", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_rules_position", + "unique": false, + "columnNames": [ + "position" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_rules_position` ON `${TABLE_NAME}` (`position`)" + } + ] + }, + { + "tableName": "show_fingerprints", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subscriptionId` TEXT NOT NULL, `kind` TEXT NOT NULL, `referenceClipPcm` BLOB, `pendingClipPcm` BLOB, `pendingEpisodeId` TEXT, `sampleRate` INTEGER NOT NULL, `confirmedCount` INTEGER NOT NULL, `lastConfirmedEpisodeId` TEXT, `updatedAtMs` INTEGER NOT NULL, PRIMARY KEY(`subscriptionId`, `kind`), FOREIGN KEY(`subscriptionId`) REFERENCES `subscriptions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "subscriptionId", + "columnName": "subscriptionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "referenceClipPcm", + "columnName": "referenceClipPcm", + "affinity": "BLOB" + }, + { + "fieldPath": "pendingClipPcm", + "columnName": "pendingClipPcm", + "affinity": "BLOB" + }, + { + "fieldPath": "pendingEpisodeId", + "columnName": "pendingEpisodeId", + "affinity": "TEXT" + }, + { + "fieldPath": "sampleRate", + "columnName": "sampleRate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "confirmedCount", + "columnName": "confirmedCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastConfirmedEpisodeId", + "columnName": "lastConfirmedEpisodeId", + "affinity": "TEXT" + }, + { + "fieldPath": "updatedAtMs", + "columnName": "updatedAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "subscriptionId", + "kind" + ] + }, + "indices": [ + { + "name": "index_show_fingerprints_subscriptionId", + "unique": false, + "columnNames": [ + "subscriptionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_show_fingerprints_subscriptionId` ON `${TABLE_NAME}` (`subscriptionId`)" + } + ], + "foreignKeys": [ + { + "table": "subscriptions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "subscriptionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "show_midrolls", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subscriptionId` TEXT NOT NULL, `id` TEXT NOT NULL, `referenceClipPcm` BLOB NOT NULL, `sampleRate` INTEGER NOT NULL, `confirmedCount` INTEGER NOT NULL, `lastConfirmedEpisodeId` TEXT, `updatedAtMs` INTEGER NOT NULL, PRIMARY KEY(`subscriptionId`, `id`), FOREIGN KEY(`subscriptionId`) REFERENCES `subscriptions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "subscriptionId", + "columnName": "subscriptionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "referenceClipPcm", + "columnName": "referenceClipPcm", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "sampleRate", + "columnName": "sampleRate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "confirmedCount", + "columnName": "confirmedCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastConfirmedEpisodeId", + "columnName": "lastConfirmedEpisodeId", + "affinity": "TEXT" + }, + { + "fieldPath": "updatedAtMs", + "columnName": "updatedAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "subscriptionId", + "id" + ] + }, + "indices": [ + { + "name": "index_show_midrolls_subscriptionId", + "unique": false, + "columnNames": [ + "subscriptionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_show_midrolls_subscriptionId` ON `${TABLE_NAME}` (`subscriptionId`)" + } + ], + "foreignKeys": [ + { + "table": "subscriptions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "subscriptionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "episode_midrolls", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`episodeId` TEXT NOT NULL, `candidateId` TEXT NOT NULL, `startMs` INTEGER NOT NULL, `endMs` INTEGER NOT NULL, PRIMARY KEY(`episodeId`, `candidateId`), FOREIGN KEY(`episodeId`) REFERENCES `episodes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "episodeId", + "columnName": "episodeId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "candidateId", + "columnName": "candidateId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startMs", + "columnName": "startMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endMs", + "columnName": "endMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "episodeId", + "candidateId" + ] + }, + "indices": [ + { + "name": "index_episode_midrolls_episodeId", + "unique": false, + "columnNames": [ + "episodeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_episode_midrolls_episodeId` ON `${TABLE_NAME}` (`episodeId`)" + } + ], + "foreignKeys": [ + { + "table": "episodes", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "episodeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5e0e6b89ad8f015fabecd87a83e4de70')" + ] + } +}
\ No newline at end of file diff --git a/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt b/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt index 1f4db52..09b669f 100644 --- a/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt +++ b/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt @@ -269,4 +269,68 @@ class MigrationTest { assertEquals(2, cursor.getInt(0)) } } + + /** + * Extending the skip feature to midroll ad breaks: the new columns default to null (no + * pending discovery episode, nothing checked yet for an existing episode), and both new + * tables are actually usable, not just present. + */ + @Test + fun migrates7To8AddingMidrollTablesAndColumns() { + helper.createDatabase(name, 7).use { db -> + db.execSQL( + """INSERT INTO subscriptions + (id, feedUrl, kind, title, addedAtMs, skipIntroMs, skipOutroMs, dripEnabled, + dripIntervalMs, dripCount, dripOldestFirst, skipAdChapters) + VALUES ('s1', 'https://example.com/feed.xml', 'PODCAST', 'A Show', 1000, 0, 0, + 0, 86400000, 1, 1, 1)""" + ) + db.execSQL( + """INSERT INTO episodes + (id, subscriptionId, guid, title, mediaUrl, mediaType, discoveredAtMs, + positionMs, downloadState) + VALUES ('e1', 's1', 'g1', 'One', 'https://cdn/1.mp3', 'audio/mpeg', 1000, + 313131, 'DOWNLOADED')""" + ) + } + + val db = helper.runMigrationsAndValidate(name, 8, true, AppDatabase.MIGRATION_7_8) + + db.query("SELECT midrollCheckedAtMs, positionMs FROM episodes WHERE id = 'e1'").use { cursor -> + cursor.moveToFirst() + assertEquals("nothing checked yet for an existing episode", true, cursor.isNull(0)) + assertEquals("the crown jewel must survive", 313131L, cursor.getLong(1)) + } + + db.query("SELECT midrollPendingEpisodeId, title FROM subscriptions WHERE id = 's1'").use { cursor -> + cursor.moveToFirst() + assertEquals(true, cursor.isNull(0)) + assertEquals("A Show", cursor.getString(1)) + } + + db.execSQL( + """INSERT INTO show_midrolls + (subscriptionId, id, referenceClipPcm, sampleRate, confirmedCount, + lastConfirmedEpisodeId, updatedAtMs) + VALUES ('s1', 'c1', X'0102', 8000, 2, 'e1', 5000)""" + ) + db.execSQL( + """INSERT INTO episode_midrolls (episodeId, candidateId, startMs, endMs) + VALUES ('e1', 'c1', 60000, 90000)""" + ) + + db.query("SELECT confirmedCount FROM show_midrolls WHERE subscriptionId = 's1' AND id = 'c1'") + .use { cursor -> + assertEquals(1, cursor.count) + cursor.moveToFirst() + assertEquals(2, cursor.getInt(0)) + } + db.query("SELECT startMs, endMs FROM episode_midrolls WHERE episodeId = 'e1' AND candidateId = 'c1'") + .use { cursor -> + assertEquals(1, cursor.count) + cursor.moveToFirst() + assertEquals(60000L, cursor.getLong(0)) + assertEquals(90000L, cursor.getLong(1)) + } + } } diff --git a/app/src/main/java/org/terst/listen/data/AppDatabase.kt b/app/src/main/java/org/terst/listen/data/AppDatabase.kt index cdfbf02..e8769d3 100644 --- a/app/src/main/java/org/terst/listen/data/AppDatabase.kt +++ b/app/src/main/java/org/terst/listen/data/AppDatabase.kt @@ -27,8 +27,10 @@ class Converters { QueueEntity::class, RuleEntity::class, ShowFingerprintEntity::class, + ShowMidrollEntity::class, + EpisodeMidrollEntity::class, ], - version = 7, + version = 8, exportSchema = true, ) @TypeConverters(Converters::class) @@ -38,6 +40,7 @@ abstract class AppDatabase : RoomDatabase() { abstract fun queue(): QueueDao abstract fun rules(): RuleDao abstract fun fingerprints(): ShowFingerprintDao + abstract fun midrolls(): MidrollDao companion object { @Volatile private var instance: AppDatabase? = null @@ -188,6 +191,45 @@ abstract class AppDatabase : RoomDatabase() { } } + /** + * Extends the skip feature's fingerprinting to midroll ad breaks: several candidates per + * show rather than one fixed intro/outro slot each (see [ShowMidrollEntity]), the + * confirmed ranges detected per episode, a per-episode "already checked" marker, and the + * one-episode-id pending pointer discovery compares the next download against. + */ + val MIGRATION_7_8 = object : Migration(7, 8) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `episodes` ADD COLUMN `midrollCheckedAtMs` INTEGER") + db.execSQL("ALTER TABLE `subscriptions` ADD COLUMN `midrollPendingEpisodeId` TEXT") + db.execSQL( + """CREATE TABLE IF NOT EXISTS `show_midrolls` ( + `subscriptionId` TEXT NOT NULL, `id` TEXT NOT NULL, + `referenceClipPcm` BLOB NOT NULL, `sampleRate` INTEGER NOT NULL, + `confirmedCount` INTEGER NOT NULL, `lastConfirmedEpisodeId` TEXT, + `updatedAtMs` INTEGER NOT NULL, + PRIMARY KEY(`subscriptionId`, `id`), + FOREIGN KEY(`subscriptionId`) REFERENCES `subscriptions`(`id`) + ON DELETE CASCADE)""" + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_show_midrolls_subscriptionId` " + + "ON `show_midrolls` (`subscriptionId`)" + ) + db.execSQL( + """CREATE TABLE IF NOT EXISTS `episode_midrolls` ( + `episodeId` TEXT NOT NULL, `candidateId` TEXT NOT NULL, + `startMs` INTEGER NOT NULL, `endMs` INTEGER NOT NULL, + PRIMARY KEY(`episodeId`, `candidateId`), + FOREIGN KEY(`episodeId`) REFERENCES `episodes`(`id`) + ON DELETE CASCADE)""" + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_episode_midrolls_episodeId` " + + "ON `episode_midrolls` (`episodeId`)" + ) + } + } + fun get(context: Context): AppDatabase = instance ?: synchronized(this) { instance ?: build(context.applicationContext).also { instance = it } } @@ -198,7 +240,7 @@ abstract class AppDatabase : RoomDatabase() { // deliberate addition, and the list is the record of them. .addMigrations( MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, - MIGRATION_6_7, + MIGRATION_6_7, MIGRATION_7_8, ) .addCallback(SEED_DEFAULT_FILTERS) // No fallbackToDestructiveMigration. A listener's playback positions and play 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 7bc54fc..541d6ef 100644 --- a/app/src/main/java/org/terst/listen/data/Daos.kt +++ b/app/src/main/java/org/terst/listen/data/Daos.kt @@ -308,6 +308,46 @@ interface ShowFingerprintDao { suspend fun setDetectedOutroMs(id: String, ms: Long) } +/** + * Everything [org.terst.listen.fingerprint.MidrollUpdater] needs: a show's tracked candidates, + * the confirmed ranges detected in one episode, and which downloaded episodes still need + * checking -- the same "lives here, not on EpisodeDao" reasoning as [ShowFingerprintDao]. + */ +@Dao +interface MidrollDao { + @Query("SELECT * FROM show_midrolls WHERE subscriptionId = :subscriptionId") + suspend fun getCandidates(subscriptionId: String): List<ShowMidrollEntity> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertCandidate(candidate: ShowMidrollEntity) + + @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") + suspend fun getRanges(episodeId: String): List<EpisodeMidrollEntity> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertRange(range: EpisodeMidrollEntity) + + /** Downloaded episodes of this show [MidrollDetector][org.terst.listen.fingerprint.MidrollDetector] + * has never looked at, oldest download first -- so a backlog of downloads is worked through + * in the order they actually landed, not newest-first forever leaving the rest unchecked. */ + @Query( + """SELECT * FROM episodes + WHERE subscriptionId = :subscriptionId AND downloadState = :downloaded + AND midrollCheckedAtMs IS NULL + ORDER BY downloadedAtMs ASC""" + ) + suspend fun getUncheckedDownloaded( + subscriptionId: String, + downloaded: DownloadState = DownloadState.DOWNLOADED, + ): List<EpisodeEntity> + + @Query("UPDATE episodes SET midrollCheckedAtMs = :ms WHERE id = :id") + suspend fun setMidrollCheckedAtMs(id: String, ms: Long) +} + @Dao interface QueueDao { @Query( diff --git a/app/src/main/java/org/terst/listen/data/Entities.kt b/app/src/main/java/org/terst/listen/data/Entities.kt index 014e3a0..4f5242d 100644 --- a/app/src/main/java/org/terst/listen/data/Entities.kt +++ b/app/src/main/java/org/terst/listen/data/Entities.kt @@ -97,6 +97,20 @@ data class SubscriptionEntity( * Changing it would mean recreating the table for a value nothing reads. */ @ColumnInfo(defaultValue = "0") val skipAdChapters: Boolean = true, + + /** + * The one episode set aside to compare the *next* newly-downloaded episode against, when + * looking for a midroll segment neither has been matched to yet. + * + * Unlike [ShowFingerprintEntity.pendingClipPcm] this stores only an id, never audio: a + * pending clip for intro/outro is a few seconds, cheap to persist between runs, but a + * pending clip here is a *whole episode* -- tens of megabytes of PCM doubles for an hour of + * audio, uselessly large to keep in a database row. [MidrollUpdater][org.terst.listen.fingerprint.MidrollUpdater] + * re-decodes this episode's file on demand instead, and if retention has already reclaimed + * it, simply moves the pending slot on to whatever is newest -- the same graceful "nothing to + * compare against yet" path as never having had a pending episode at all. + */ + val midrollPendingEpisodeId: String? = null, ) /** One a day, the cadence a drip subscription is for. */ @@ -160,6 +174,15 @@ data class EpisodeEntity( val speedOverride: Float? = null, /** + * When [MidrollDetector][org.terst.listen.fingerprint.MidrollDetector] last checked this + * episode against its show's tracked candidates, or null if it never has. Per-episode rather + * than a show-level cursor: downloads do not always land in publish order, and a marker per + * episode means a backfilled older download still gets checked instead of being silently + * skipped by a pointer that already moved past it. + */ + val midrollCheckedAtMs: Long? = null, + + /** * The fetched Podcasting 2.0 chapter file, verbatim, or null if there is none yet. * * Stored as the raw document rather than parsed rows: it is only ever read whole, for one @@ -313,6 +336,88 @@ data class ShowFingerprintEntity( } /** + * A show's progress toward one trusted, automatically-detected midroll ad break. + * + * One row per *candidate*, not one per show like [ShowFingerprintEntity] -- a show can have + * several distinct midroll breaks running at once (and Peter's own Darknet Diaries prototype run + * found exactly that: a recurring segment independent of the fixed intro), so [id] is a generated + * identifier rather than a fixed kind. [MidrollScanner] is what ever produces a new row; this + * table only ever tracks candidates that already exist. + */ +@Entity( + tableName = "show_midrolls", + primaryKeys = ["subscriptionId", "id"], + foreignKeys = [ + ForeignKey( + entity = SubscriptionEntity::class, + parentColumns = ["id"], + childColumns = ["subscriptionId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index(value = ["subscriptionId"])], +) +data class ShowMidrollEntity( + val subscriptionId: String, + val id: String, + /** The matched segment itself, 16-bit PCM via [org.terst.listen.fingerprint.PcmCodec]. */ + val referenceClipPcm: ByteArray, + val sampleRate: Int, + val confirmedCount: Int, + val lastConfirmedEpisodeId: String?, + val updatedAtMs: Long, +) { + // See ShowFingerprintEntity's identical override for why: a ByteArray property breaks the + // generated equals/hashCode, comparing references rather than contents. + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ShowMidrollEntity) return false + return subscriptionId == other.subscriptionId && + id == other.id && + referenceClipPcm.contentEquals(other.referenceClipPcm) && + sampleRate == other.sampleRate && + confirmedCount == other.confirmedCount && + lastConfirmedEpisodeId == other.lastConfirmedEpisodeId && + updatedAtMs == other.updatedAtMs + } + + override fun hashCode(): Int { + var result = subscriptionId.hashCode() + result = 31 * result + id.hashCode() + result = 31 * result + referenceClipPcm.contentHashCode() + result = 31 * result + sampleRate + result = 31 * result + confirmedCount + result = 31 * result + (lastConfirmedEpisodeId?.hashCode() ?: 0) + result = 31 * result + updatedAtMs.hashCode() + return result + } +} + +/** + * One confirmed midroll range detected in one episode -- where to jump from and to, in + * milliseconds into the episode. An episode can carry several, one per candidate that matched it. + */ +@Entity( + tableName = "episode_midrolls", + primaryKeys = ["episodeId", "candidateId"], + foreignKeys = [ + ForeignKey( + entity = EpisodeEntity::class, + parentColumns = ["id"], + childColumns = ["episodeId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index(value = ["episodeId"])], +) +data class EpisodeMidrollEntity( + val episodeId: String, + val candidateId: String, + val startMs: Long, + val endMs: Long, +) + +/** * One entry in the single Up Next queue. * * Position is a plain ordinal rewritten on reorder. A personal queue is short enough that the 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 ddcf820..fb77ab6 100644 --- a/app/src/main/java/org/terst/listen/data/Repositories.kt +++ b/app/src/main/java/org/terst/listen/data/Repositories.kt @@ -51,6 +51,7 @@ class AppGraph(private val context: Context) { val queueDao: QueueDao get() = db.queue() val ruleDao: RuleDao get() = db.rules() val fingerprints: ShowFingerprintDao get() = db.fingerprints() + val midrolls: MidrollDao get() = db.midrolls() val libraryDir: File by lazy { File(context.filesDir, "audio").also { it.mkdirs() } @@ -71,6 +72,7 @@ class AppGraph(private val context: Context) { val advancer: PlaybackAdvancer by lazy { PlaybackAdvancer(episodes, subscriptions, queue, sources = object : PlaybackSources { override suspend fun chapters(episode: EpisodeEntity) = chapters.chapters(episode) + override suspend fun midrolls(episode: EpisodeEntity) = midrolls.getRanges(episode.id) override suspend fun fetchAudio(episode: EpisodeEntity, kind: SubscriptionKind) = downloader.download(episode, kind).map { } }) diff --git a/app/src/main/java/org/terst/listen/fingerprint/Decimate.kt b/app/src/main/java/org/terst/listen/fingerprint/Decimate.kt new file mode 100644 index 0000000..9b7acd4 --- /dev/null +++ b/app/src/main/java/org/terst/listen/fingerprint/Decimate.kt @@ -0,0 +1,24 @@ +package org.terst.listen.fingerprint + +/** + * Downsamples by averaging fixed-size blocks, rather than dropping samples. + * + * Only used as a cheap pre-filter ahead of [MidrollScanner]'s coarse pass: averaging keeps the + * signal's overall shape (which is all a coarse alignment search needs) where naive decimation + * (keeping every Nth sample) would alias, and correctness of the *real* match still comes from + * [AudioCorrelation] re-checking at full resolution afterwards. + */ +object Decimate { + + fun byAveraging(samples: DoubleArray, factor: Int): DoubleArray { + require(factor >= 1) { "factor must be at least 1" } + if (factor == 1) return samples + val outLength = samples.size / factor + return DoubleArray(outLength) { i -> + var sum = 0.0 + val base = i * factor + for (j in 0 until factor) sum += samples[base + j] + sum / factor + } + } +} diff --git a/app/src/main/java/org/terst/listen/fingerprint/FingerprintUpdater.kt b/app/src/main/java/org/terst/listen/fingerprint/FingerprintUpdater.kt index 6a71d83..dc627fa 100644 --- a/app/src/main/java/org/terst/listen/fingerprint/FingerprintUpdater.kt +++ b/app/src/main/java/org/terst/listen/fingerprint/FingerprintUpdater.kt @@ -19,6 +19,14 @@ interface AudioClipSource { /** Null if the episode has nothing playable yet, or decoding otherwise fails. */ suspend fun decodeHead(episode: EpisodeEntity): DecodedClip? suspend fun decodeTail(episode: EpisodeEntity): DecodedClip? + + /** + * The episode's *entire* decoded audio, for [MidrollUpdater] -- a midroll ad break can land + * anywhere in the body, so unlike [decodeHead]/[decodeTail] there is no fixed short window to + * ask for. Null under the same conditions, plus whenever the episode's length is not known + * up front (needed to size the decode). + */ + suspend fun decodeFull(episode: EpisodeEntity): DecodedClip? } /** diff --git a/app/src/main/java/org/terst/listen/fingerprint/MediaCodecClipSource.kt b/app/src/main/java/org/terst/listen/fingerprint/MediaCodecClipSource.kt index be140f9..133d391 100644 --- a/app/src/main/java/org/terst/listen/fingerprint/MediaCodecClipSource.kt +++ b/app/src/main/java/org/terst/listen/fingerprint/MediaCodecClipSource.kt @@ -25,22 +25,35 @@ class MediaCodecClipSource : AudioClipSource { override suspend fun decodeHead(episode: EpisodeEntity): DecodedClip? { val path = episode.localPath ?: return null - return decode(path, startMs = 0) + return decode(path, startMs = 0, CLIP_SECONDS) } override suspend fun decodeTail(episode: EpisodeEntity): DecodedClip? { val path = episode.localPath ?: return null val totalMs = episode.durationMs ?: return null val startMs = (totalMs - CLIP_SECONDS * MS_PER_SECOND).coerceAtLeast(0) - return decode(path, startMs) + return decode(path, startMs, CLIP_SECONDS) } - private suspend fun decode(path: String, startMs: Long): DecodedClip? = + /** + * Unlike [decodeHead]/[decodeTail], there is no fixed window: a midroll ad can be anywhere, + * so the whole episode is decoded. [EpisodeEntity.durationMs] sizes the buffer up front for + * the same reason [decodeToPcm] does for a short clip -- a boxed, growable collection for + * what can be hours of samples would be real, avoidable garbage on a phone. + */ + override suspend fun decodeFull(episode: EpisodeEntity): DecodedClip? { + val path = episode.localPath ?: return null + val totalMs = episode.durationMs ?: return null + val clipSeconds = (totalMs / MS_PER_SECOND + FULL_DECODE_MARGIN_SECONDS).toInt() + return decode(path, startMs = 0, clipSeconds) + } + + private suspend fun decode(path: String, startMs: Long, clipSeconds: Int): DecodedClip? = withContext(Dispatchers.Default) { - runCatching { decodeBlocking(path, startMs) }.getOrNull() + runCatching { decodeBlocking(path, startMs, clipSeconds) }.getOrNull() } - private fun decodeBlocking(path: String, startMs: Long): DecodedClip { + private fun decodeBlocking(path: String, startMs: Long, clipSeconds: Int): DecodedClip { val extractor = MediaExtractor() try { extractor.setDataSource(path) @@ -51,7 +64,7 @@ class MediaCodecClipSource : AudioClipSource { val channels = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT) extractor.seekTo(startMs * MICROS_PER_MS, MediaExtractor.SEEK_TO_CLOSEST_SYNC) - val raw = decodeToPcm(extractor, format, inputRate, channels) + val raw = decodeToPcm(extractor, format, inputRate, channels, clipSeconds) val mono = downmix(raw, channels) val resampled = resample(mono, inputRate, TARGET_SAMPLE_RATE) return DecodedClip(resampled, TARGET_SAMPLE_RATE, startMs) @@ -68,8 +81,14 @@ class MediaCodecClipSource : AudioClipSource { error("No audio track in this file") } - /** Interleaved PCM16 samples for up to [CLIP_SECONDS] of decoded audio. */ - private fun decodeToPcm(extractor: MediaExtractor, format: MediaFormat, inputRate: Int, channels: Int): ShortArray { + /** Interleaved PCM16 samples for up to [clipSeconds] of decoded audio. */ + private fun decodeToPcm( + extractor: MediaExtractor, + format: MediaFormat, + inputRate: Int, + channels: Int, + clipSeconds: Int, + ): ShortArray { val mime = format.getString(MediaFormat.KEY_MIME) ?: error("No MIME on the audio track") val codec = MediaCodec.createDecoderByType(mime) try { @@ -78,7 +97,7 @@ class MediaCodecClipSource : AudioClipSource { // Sized to the clip length up front rather than grown: a boxed collection for // several million samples would be real, avoidable garbage on a phone. - val target = ShortArray((CLIP_SECONDS.toLong() * inputRate * channels).toInt()) + val target = ShortArray((clipSeconds.toLong() * inputRate * channels).toInt()) var written = 0 var inputDone = false var outputDone = false @@ -171,6 +190,8 @@ class MediaCodecClipSource : AudioClipSource { private companion object { const val CLIP_SECONDS = 240 + // durationMs is the feed's own claim, occasionally a little short of the real file. + const val FULL_DECODE_MARGIN_SECONDS = 30 const val TARGET_SAMPLE_RATE = 8_000 const val TIMEOUT_US = 10_000L const val MICROS_PER_MS = 1_000L diff --git a/app/src/main/java/org/terst/listen/fingerprint/MidrollDetector.kt b/app/src/main/java/org/terst/listen/fingerprint/MidrollDetector.kt new file mode 100644 index 0000000..b33f577 --- /dev/null +++ b/app/src/main/java/org/terst/listen/fingerprint/MidrollDetector.kt @@ -0,0 +1,90 @@ +package org.terst.listen.fingerprint + +/** + * Decides whether each of a show's tracked midroll candidates still holds up against one more + * episode, and reports which ones are trusted enough to act on. + * + * The confirm/discard shape is exactly [IntroOutroDetector]'s -- the same Darknet Diaries trap + * applies here too, a segment two episodes happen to share by coincidence must not survive a + * genuinely independent episode that lacks it. The difference is scale, not kind: a show can have + * several distinct midroll breaks at once, each tracked as its own [Candidate] rather than one + * fixed intro/outro slot, and *finding* a new one means scanning a whole episode body (see + * [MidrollScanner]) rather than comparing two short fixed-position clips -- which is a separate, + * heavier concern this object deliberately knows nothing about. [MidrollUpdater] owns discovery; + * this only ever judges candidates that already exist. + */ +object MidrollDetector { + + private const val CONFIRM_THRESHOLD = 3 + private const val MIN_SCORE = 0.6 + + data class Candidate( + val id: String, + val referenceClip: DoubleArray, + val confirmedCount: Int, + val lastConfirmedEpisodeId: String? = null, + ) + + /** Where a candidate matched in the episode just checked, in samples. */ + data class Detected(val candidateId: String, val positionInClip: Int, val lengthSamples: Int) + + /** + * [detected] is only the candidates that crossed [confirmThreshold] this round -- the ones + * safe to actually act on. [allMatches] is every survivor's match this round, tentative ones + * included: [MidrollUpdater] needs that wider set to recognise a fresh discovery hit as the + * same still-unconfirmed candidate rather than minting a duplicate for the same segment. + */ + data class Result( + val candidates: List<Candidate>, + val detected: List<Detected>, + val allMatches: List<Detected>, + ) + + /** What checking one candidate against the episode decided. */ + private sealed interface Outcome { + data class Kept(val candidate: Candidate, val match: Detected?) : Outcome + data object Discarded : Outcome + } + + /** + * Checks every candidate in [candidates] against [fullClip] (a whole episode's decoded + * audio), independently. A candidate the episode does not confirm is dropped from the + * returned list entirely -- not weakened -- the same discard-on-mismatch discipline as + * [IntroOutroDetector]. + */ + fun check( + candidates: List<Candidate>, + episodeId: String, + fullClip: DoubleArray, + confirmThreshold: Int = CONFIRM_THRESHOLD, + ): Result { + val survivors = mutableListOf<Candidate>() + val detected = mutableListOf<Detected>() + val allMatches = mutableListOf<Detected>() + + for (candidate in candidates) { + when (val outcome = evaluate(candidate, episodeId, fullClip)) { + is Outcome.Discarded -> Unit + is Outcome.Kept -> { + survivors += outcome.candidate + val match = outcome.match ?: continue + allMatches += match + if (outcome.candidate.confirmedCount >= confirmThreshold) detected += match + } + } + } + + return Result(survivors, detected, allMatches) + } + + private fun evaluate(candidate: Candidate, episodeId: String, fullClip: DoubleArray): Outcome { + if (candidate.lastConfirmedEpisodeId == episodeId) return Outcome.Kept(candidate, match = null) + + val match = AudioCorrelation.bestMatch(candidate.referenceClip, fullClip) + if (match == null || match.score < MIN_SCORE) return Outcome.Discarded + + val bumped = candidate.copy(confirmedCount = candidate.confirmedCount + 1, lastConfirmedEpisodeId = episodeId) + val detected = Detected(bumped.id, match.position, candidate.referenceClip.size) + return Outcome.Kept(bumped, detected) + } +} diff --git a/app/src/main/java/org/terst/listen/fingerprint/MidrollScanner.kt b/app/src/main/java/org/terst/listen/fingerprint/MidrollScanner.kt new file mode 100644 index 0000000..d69870c --- /dev/null +++ b/app/src/main/java/org/terst/listen/fingerprint/MidrollScanner.kt @@ -0,0 +1,79 @@ +package org.terst.listen.fingerprint + +/** + * Finds every segment [a] and [b] share, at whatever position each recording happens to have it + * -- unlike [IntroOutroDetector], which only ever looks for one candidate anchored at the head or + * tail. A show's midroll ad breaks land at different timestamps in every episode, so discovering + * one means scanning the whole body of both episodes rather than a short fixed-position window. + * + * Single-resolution and pure: given whatever `a`/`b` it is handed, it does not know or care that + * the real caller runs it twice at different sample rates (see the coarse-to-fine composition in + * [MidrollUpdater]) -- that split exists purely so a hand-length scan of a full episode is fast + * enough to run on a phone, and has nothing to do with correctness. + */ +object MidrollScanner { + + /** A segment found at [positionInA] in `a` and [positionInB] in `b`, both in samples. */ + data class SharedSegment( + val positionInA: Int, + val positionInB: Int, + val lengthSamples: Int, + val score: Double, + ) + + /** + * Slides a [windowSamples]-long window across [a] every [stepSamples], keeping wherever it + * best matches somewhere in [b]. Hits are merged into one [SharedSegment] when they fall on + * the same diagonal (`positionInA - positionInB` within a step of each other) -- consecutive + * windows walking across the same real embedded segment, not two different ones. + */ + fun findSharedSegments( + a: DoubleArray, + b: DoubleArray, + windowSamples: Int, + stepSamples: Int, + minScore: Double, + ): List<SharedSegment> { + if (windowSamples <= 0 || windowSamples > a.size || windowSamples > b.size) return emptyList() + + val groups = mutableListOf<Group>() + var start = 0 + while (start + windowSamples <= a.size) { + val window = a.copyOfRange(start, start + windowSamples) + val match = AudioCorrelation.bestMatch(window, b) + if (match != null && match.score >= minScore) { + place(groups, Hit(aStart = start, bStart = match.position, score = match.score), windowSamples, stepSamples) + } + start += stepSamples + } + + return groups.map { + SharedSegment( + positionInA = it.aStart, + positionInB = it.bStart, + lengthSamples = it.aEnd - it.aStart, + score = it.bestScore, + ) + } + } + + private class Group(var aStart: Int, var aEnd: Int, var bStart: Int, var bEnd: Int, var bestScore: Double) + + private data class Hit(val aStart: Int, val bStart: Int, val score: Double) + + private fun place(groups: MutableList<Group>, hit: Hit, windowSamples: Int, tolerance: Int) { + val aEnd = hit.aStart + windowSamples + val bEnd = hit.bStart + windowSamples + val diagonal = hit.aStart - hit.bStart + val existing = groups.firstOrNull { kotlin.math.abs((it.aStart - it.bStart) - diagonal) <= tolerance } + if (existing == null) { + groups += Group(hit.aStart, aEnd, hit.bStart, bEnd, hit.score) + } else { + existing.aStart = minOf(existing.aStart, hit.aStart) + existing.aEnd = maxOf(existing.aEnd, aEnd) + existing.bStart = minOf(existing.bStart, hit.bStart) + existing.bEnd = maxOf(existing.bEnd, bEnd) + existing.bestScore = maxOf(existing.bestScore, hit.score) + } + } +} diff --git a/app/src/main/java/org/terst/listen/fingerprint/MidrollUpdater.kt b/app/src/main/java/org/terst/listen/fingerprint/MidrollUpdater.kt new file mode 100644 index 0000000..47f9184 --- /dev/null +++ b/app/src/main/java/org/terst/listen/fingerprint/MidrollUpdater.kt @@ -0,0 +1,226 @@ +package org.terst.listen.fingerprint + +import java.util.UUID +import org.terst.listen.data.EpisodeDao +import org.terst.listen.data.EpisodeEntity +import org.terst.listen.data.EpisodeMidrollEntity +import org.terst.listen.data.MidrollDao +import org.terst.listen.data.ShowMidrollEntity +import org.terst.listen.data.SubscriptionDao + +/** + * Runs [MidrollDetector] for every downloaded episode a show has not yet been checked against, + * and discovers new candidates via [MidrollScanner] when there is room and something to compare + * against. + * + * The one thing [FingerprintUpdater] never has to do that this does: decide *when* to spend the + * cost of a full-body scan. Comparing two whole episodes is expensive enough that it only ever + * happens between the show's single rolling "pending" episode and whatever is newest -- see + * [org.terst.listen.data.SubscriptionEntity.midrollPendingEpisodeId] for why no audio is + * persisted for it between runs. + */ +class MidrollUpdater( + private val subscriptions: SubscriptionDao, + private val episodes: EpisodeDao, + private val midrolls: MidrollDao, + private val clips: AudioClipSource, + private val tuning: Tuning = Tuning(), +) { + + /** Every knob this class needs, grouped so the constructor stays a fixed, readable size no + * matter how many of them a test needs to override at once. */ + data class Tuning( + /** How much to shrink audio before the coarse discovery pass. 1 disables it entirely. */ + val coarseFactor: Int = DEFAULT_COARSE_FACTOR, + /** The coarse-pass window, in already-decimated samples -- see [MidrollScanner]. */ + val discoverySamples: Int = DEFAULT_DISCOVERY_SAMPLES, + val maxCandidates: Int = DEFAULT_MAX_CANDIDATES, + val confirmThreshold: Int = DEFAULT_CONFIRM_THRESHOLD, + val newId: () -> String = { UUID.randomUUID().toString() }, + val now: () -> Long = System::currentTimeMillis, + ) + + private val coarseFactor get() = tuning.coarseFactor + private val discoverySamples get() = tuning.discoverySamples + private val maxCandidates get() = tuning.maxCandidates + private val confirmThreshold get() = tuning.confirmThreshold + private val newId get() = tuning.newId + private val now get() = tuning.now + + suspend fun processShow(subscriptionId: String) { + val show = subscriptions.getById(subscriptionId) ?: return + var pendingEpisodeId = show.midrollPendingEpisodeId + + for (episode in midrolls.getUncheckedDownloaded(subscriptionId)) { + val decoded = clips.decodeFull(episode) + if (decoded == null) { + midrolls.setMidrollCheckedAtMs(episode.id, now()) + continue + } + + val stored = midrolls.getCandidates(subscriptionId) + val result = MidrollDetector.check( + stored.map(::toDetectorCandidate), + episode.id, + decoded.samples, + confirmThreshold, + ) + persist(subscriptionId, stored, result, decoded.sampleRate) + for (detected in result.detected) applyDetected(episode, decoded, detected) + + if (result.candidates.size < maxCandidates) { + pendingEpisodeId?.takeIf { it != episode.id }?.let { pendingId -> + discoverAgainstPending(subscriptionId, pendingId, episode, decoded, result.allMatches) + } + pendingEpisodeId = episode.id + } + + midrolls.setMidrollCheckedAtMs(episode.id, now()) + } + + if (pendingEpisodeId != show.midrollPendingEpisodeId) { + subscriptions.update(show.copy(midrollPendingEpisodeId = pendingEpisodeId)) + } + } + + private suspend fun discoverAgainstPending( + subscriptionId: String, + pendingEpisodeId: String, + newEpisode: EpisodeEntity, + newClip: DecodedClip, + alreadyMatchedInNewEpisode: List<MidrollDetector.Detected>, + ) { + val pendingEpisode = episodes.getById(pendingEpisodeId) ?: return + val pendingClip = clips.decodeFull(pendingEpisode) ?: return + + val tolerance = discoverySamples.toLong() * coarseFactor + for (segment in discover(pendingClip.samples, newClip.samples)) { + // The same real segment surfaces from a fresh discovery pass every time the pending + // pointer rolls forward -- e2 vs e3 finds the same ad e1 vs e2 already tracked. + // Recognised here by *where* it landed in the new episode, not by re-correlating + // clip content: two coarse passes over the same segment do not reliably extract the + // same sub-range of it, so a content comparison between them can score low even + // though both come from the one real ad break -- position overlap is what is + // actually stable between runs. + val overlapsTracked = alreadyMatchedInNewEpisode.any { existing -> + rangesOverlap( + segment.positionInB - tolerance, segment.positionInB + segment.lengthInB + tolerance, + existing.positionInClip.toLong(), existing.positionInClip.toLong() + existing.lengthSamples, + ) + } + if (overlapsTracked) continue + + midrolls.upsertCandidate( + ShowMidrollEntity( + subscriptionId = subscriptionId, + id = newId(), + referenceClipPcm = PcmCodec.encode(segment.referenceClip), + sampleRate = newClip.sampleRate, + confirmedCount = SEEDED_CONFIRM_COUNT, + lastConfirmedEpisodeId = newEpisode.id, + updatedAtMs = now(), + ) + ) + } + } + + private fun rangesOverlap(aStart: Long, aEnd: Long, bStart: Long, bEnd: Long) = aStart < bEnd && bStart < aEnd + + private data class DiscoveredSegment(val referenceClip: DoubleArray, val positionInB: Long, val lengthInB: Long) + + /** + * Coarse-to-fine: [MidrollScanner] runs once on decimated copies to find candidate + * alignments cheaply, then each candidate is re-sliced from the *native*-resolution audio and + * re-checked with a single [AudioCorrelation.bestMatch] -- both for a trustworthy score and + * to reject anything the decimation made look like a match but is not. See the desktop + * prototype findings this composition is based on for why the coarse pass alone is not + * trusted: it is a performance shortcut, not where correctness comes from. + */ + private fun discover(a: DoubleArray, b: DoubleArray): List<DiscoveredSegment> { + val coarseA = Decimate.byAveraging(a, coarseFactor) + val coarseB = Decimate.byAveraging(b, coarseFactor) + val step = (discoverySamples / 2).coerceAtLeast(1) + val hits = MidrollScanner.findSharedSegments(coarseA, coarseB, discoverySamples, step, COARSE_MIN_SCORE) + return hits.mapNotNull { extractSegment(it, a, b) } + } + + /** + * Re-slices one coarse hit at native resolution and verifies it with a single + * [AudioCorrelation.bestMatch] -- both for a trustworthy score and to reject anything the + * decimation made look like a match but is not. Null covers every way that can fail: an empty + * slice at either end, or a score that does not hold up at full resolution. + */ + private fun extractSegment(hit: MidrollScanner.SharedSegment, a: DoubleArray, b: DoubleArray): DiscoveredSegment? { + val aStart = (hit.positionInA.toLong() * coarseFactor).coerceIn(0, a.size.toLong()).toInt() + val aEnd = ((hit.positionInA.toLong() + hit.lengthSamples) * coarseFactor).coerceIn(0, a.size.toLong()).toInt() + if (aEnd - aStart <= 0) return null + val query = a.copyOfRange(aStart, aEnd).let { + if (it.size > MAX_REFERENCE_SAMPLES) it.copyOf(MAX_REFERENCE_SAMPLES) else it + } + + val margin = discoverySamples.toLong() * coarseFactor + val bStart = ((hit.positionInB.toLong() * coarseFactor) - margin).coerceIn(0, b.size.toLong()).toInt() + val bEnd = ((hit.positionInB.toLong() + hit.lengthSamples) * coarseFactor + margin) + .coerceIn(0, b.size.toLong()).toInt() + if (bEnd - bStart < query.size) return null + val target = b.copyOfRange(bStart, bEnd) + + val match = AudioCorrelation.bestMatch(query, target) ?: return null + if (match.score < FINE_MIN_SCORE) return null + return DiscoveredSegment(query, (bStart + match.position).toLong(), query.size.toLong()) + } + + private suspend fun persist( + subscriptionId: String, + stored: List<ShowMidrollEntity>, + result: MidrollDetector.Result, + sampleRate: Int, + ) { + val surviving = result.candidates.associateBy { it.id } + for (row in stored) { + if (row.id !in surviving) midrolls.deleteCandidate(subscriptionId, row.id) + } + for (candidate in result.candidates) { + midrolls.upsertCandidate( + ShowMidrollEntity( + subscriptionId = subscriptionId, + id = candidate.id, + referenceClipPcm = PcmCodec.encode(candidate.referenceClip), + sampleRate = sampleRate, + confirmedCount = candidate.confirmedCount, + lastConfirmedEpisodeId = candidate.lastConfirmedEpisodeId, + updatedAtMs = now(), + ) + ) + } + } + + private suspend fun applyDetected( + episode: EpisodeEntity, + clip: DecodedClip, + detected: MidrollDetector.Detected, + ) { + val startMs = clip.startMs + detected.positionInClip.toLong() * MS_PER_SECOND / clip.sampleRate + val endMs = startMs + detected.lengthSamples.toLong() * MS_PER_SECOND / clip.sampleRate + midrolls.upsertRange(EpisodeMidrollEntity(episode.id, detected.candidateId, startMs, endMs)) + } + + private fun toDetectorCandidate(entity: ShowMidrollEntity) = MidrollDetector.Candidate( + id = entity.id, + referenceClip = PcmCodec.decode(entity.referenceClipPcm), + confirmedCount = entity.confirmedCount, + lastConfirmedEpisodeId = entity.lastConfirmedEpisodeId, + ) + + private companion object { + const val DEFAULT_COARSE_FACTOR = 8 + const val DEFAULT_DISCOVERY_SAMPLES = 20_000 // 20s at the coarse (post-decimation) rate + const val DEFAULT_MAX_CANDIDATES = 4 + const val DEFAULT_CONFIRM_THRESHOLD = 3 + const val SEEDED_CONFIRM_COUNT = 2 + const val COARSE_MIN_SCORE = 0.7 + const val FINE_MIN_SCORE = 0.6 + const val MAX_REFERENCE_SAMPLES = 180 * 8_000 // 3 minutes at 8kHz, a generous cap + const val MS_PER_SECOND = 1_000L + } +} diff --git a/app/src/main/java/org/terst/listen/playback/MidrollSkip.kt b/app/src/main/java/org/terst/listen/playback/MidrollSkip.kt new file mode 100644 index 0000000..fdaf7a3 --- /dev/null +++ b/app/src/main/java/org/terst/listen/playback/MidrollSkip.kt @@ -0,0 +1,18 @@ +package org.terst.listen.playback + +import org.terst.listen.data.EpisodeMidrollEntity + +/** + * Where to jump to if the playhead has entered a midroll ad break Tier 3 confirmed for this + * episode, or null to carry on playing. + * + * Same shape as [AdChapters], and the same reason: [PlayheadPolicy] just wants "is there + * somewhere to jump to right now", regardless of which of the two sources decided that. + */ +object MidrollSkip { + + fun skipTarget(ranges: List<EpisodeMidrollEntity>, positionMs: Long): Long? = + ranges.firstOrNull { positionMs >= it.startMs && positionMs < it.endMs } + ?.endMs + ?.takeIf { it > positionMs } +} diff --git a/app/src/main/java/org/terst/listen/playback/PlaybackAdvancer.kt b/app/src/main/java/org/terst/listen/playback/PlaybackAdvancer.kt index 8aa0bf0..51bef62 100644 --- a/app/src/main/java/org/terst/listen/playback/PlaybackAdvancer.kt +++ b/app/src/main/java/org/terst/listen/playback/PlaybackAdvancer.kt @@ -2,6 +2,7 @@ package org.terst.listen.playback import org.terst.listen.data.EpisodeDao import org.terst.listen.data.EpisodeEntity +import org.terst.listen.data.EpisodeMidrollEntity import org.terst.listen.data.QueueRepository import org.terst.listen.data.SubscriptionDao import org.terst.listen.data.SubscriptionEntity @@ -13,22 +14,28 @@ data class PlayingContext( val show: SubscriptionEntity? = null, val chapters: List<Chapter> = emptyList(), val episode: EpisodeEntity? = null, + /** Tier 3 midroll breaks confirmed for this episode -- see [MidrollSkip]. */ + val midrolls: List<EpisodeMidrollEntity> = emptyList(), ) /** - * The parts of starting an episode that reach the network: its chapter file, and yt-dlp. - * - * One seam rather than two lambdas on the constructor, so a test stands in for both at once and - * the class keeps a readable signature. + * Everything about starting or scoring an episode that lives outside [PlaybackAdvancer]'s own + * state: its chapter file and yt-dlp reach the network, and its detected midroll ranges reach the + * database -- grouped behind one seam regardless, so a test stands in for all of it at once and + * the constructor stays a readable, fixed size no matter how many small lookups this ends up + * covering. */ interface PlaybackSources { suspend fun chapters(episode: EpisodeEntity): List<Chapter> + suspend fun midrolls(episode: EpisodeEntity): List<EpisodeMidrollEntity> + suspend fun fetchAudio(episode: EpisodeEntity, kind: SubscriptionKind): Result<Unit> - /** For the cases that never reach the network: no chapters, and nothing to extract with. */ + /** For the cases that never reach outside: no chapters, no midrolls, nothing to extract with. */ object None : PlaybackSources { override suspend fun chapters(episode: EpisodeEntity) = emptyList<Chapter>() + override suspend fun midrolls(episode: EpisodeEntity) = emptyList<EpisodeMidrollEntity>() override suspend fun fetchAudio(episode: EpisodeEntity, kind: SubscriptionKind) = Result.failure<Unit>(IllegalStateException("no audio source configured")) } @@ -83,24 +90,25 @@ class PlaybackAdvancer( private val episodes: EpisodeDao, private val subscriptions: SubscriptionDao, private val queue: QueueRepository, - /** Everything here that needs the network, behind one seam so tests can stand it in. */ + /** Everything here that reaches outside this class's own state, behind one seam so tests can + * stand it all in at once. */ private val sources: PlaybackSources = PlaybackSources.None, private val progress: PlaybackProgress = PlaybackProgress(), private val now: () -> Long = System::currentTimeMillis, ) { /** - * The show and chapters behind an episode. + * The show, chapters and detected midroll breaks behind an episode. * * Read once when the playing item changes rather than on every tick: the ticker runs every - * two seconds for hours at a stretch and neither answer changes mid-episode. + * two seconds for hours at a stretch and none of these answers change mid-episode. */ suspend fun contextFor(episodeId: String?): PlayingContext { val episode = episodeId?.let { episodes.getById(it) } ?: return PlayingContext() val show = subscriptions.getById(episode.subscriptionId) // Only worth fetching for a show that asked for it; most publish no chapters at all. val chapters = if (show?.skipAdChapters == true) sources.chapters(episode) else emptyList() - return PlayingContext(show, chapters, episode) + return PlayingContext(show, chapters, episode, sources.midrolls(episode)) } /** 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 5d7f7f6..2eff5be 100644 --- a/app/src/main/java/org/terst/listen/playback/PlayheadPolicy.kt +++ b/app/src/main/java/org/terst/listen/playback/PlayheadPolicy.kt @@ -31,6 +31,10 @@ 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. + MidrollSkip.skipTarget(context.midrolls, positionMs)?.let { return TickAction.SeekTo(it) } + if (!show.skipAdChapters) return TickAction.Nothing return AdChapters.skipTarget(context.chapters, positionMs, durationMs) ?.let(TickAction::SeekTo) diff --git a/app/src/main/java/org/terst/listen/work/Workers.kt b/app/src/main/java/org/terst/listen/work/Workers.kt index 44fd497..a085070 100644 --- a/app/src/main/java/org/terst/listen/work/Workers.kt +++ b/app/src/main/java/org/terst/listen/work/Workers.kt @@ -14,6 +14,7 @@ import org.terst.listen.data.Graph import org.terst.listen.data.RefreshOutcome import org.terst.listen.fingerprint.FingerprintUpdater import org.terst.listen.fingerprint.MediaCodecClipSource +import org.terst.listen.fingerprint.MidrollUpdater import java.util.concurrent.TimeUnit /** @@ -93,15 +94,33 @@ class FingerprintWorker(context: Context, params: WorkerParameters) : CoroutineW } } +/** + * Runs [MidrollUpdater] for every show, nightly rather than per-download like [FingerprintWorker]: + * a full-body scan is expensive enough (see the desktop prototype findings) that it is worth + * batching, and unlike a download it never touches the network, so there is nothing per-episode + * to react to promptly. + */ +class MidrollWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result { + val graph = Graph.get(applicationContext) + val updater = MidrollUpdater(graph.subscriptions, graph.episodes, graph.midrolls, MediaCodecClipSource()) + for (show in graph.subscriptions.getAll()) updater.processShow(show.id) + return Result.success() + } +} + object Scheduling { private const val REFRESH_WORK = "listen-refresh" private const val DOWNLOAD_WORK = "listen-download" private const val DOWNLOAD_NOW_WORK = "listen-download-now" private const val RETENTION_WORK = "listen-retention" + private const val MIDROLL_WORK = "listen-midroll" private const val REFRESH_HOURS = 6L private const val DOWNLOAD_HOURS = 2L private const val RETENTION_HOURS = 12L + private const val MIDROLL_HOURS = 24L /** * Fetches what is in the queue now, rather than at the next periodic sweep. @@ -176,5 +195,19 @@ object Scheduling { .setConstraints(Constraints.Builder().setRequiresBatteryNotLow(true).build()) .build(), ) + + manager.enqueueUniquePeriodicWork( + MIDROLL_WORK, + ExistingPeriodicWorkPolicy.KEEP, + PeriodicWorkRequestBuilder<MidrollWorker>(MIDROLL_HOURS, TimeUnit.HOURS) + .setConstraints( + Constraints.Builder() + // Charging, not wifi: everything it touches is already on disk, so + // unlike DownloadWorker there is no data cost to gate on network for. + .setRequiresCharging(true) + .build() + ) + .build(), + ) } } diff --git a/app/src/test/java/org/terst/listen/data/FakeDaos.kt b/app/src/test/java/org/terst/listen/data/FakeDaos.kt index 43f4067..16f46f4 100644 --- a/app/src/test/java/org/terst/listen/data/FakeDaos.kt +++ b/app/src/test/java/org/terst/listen/data/FakeDaos.kt @@ -272,6 +272,44 @@ class FakeShowFingerprintDao : ShowFingerprintDao { } } +class FakeMidrollDao : MidrollDao { + val candidateRows = mutableListOf<ShowMidrollEntity>() + val rangeRows = mutableListOf<EpisodeMidrollEntity>() + + /** Wired the same way as [FakeShowFingerprintDao.episodeRows]: backs the queries this DAO + * runs against the real `episodes` table. */ + var episodeRows: MutableList<EpisodeEntity> = mutableListOf() + + override suspend fun getCandidates(subscriptionId: String) = + candidateRows.filter { it.subscriptionId == subscriptionId } + + override suspend fun upsertCandidate(candidate: ShowMidrollEntity) { + candidateRows.removeAll { it.subscriptionId == candidate.subscriptionId && it.id == candidate.id } + candidateRows += candidate + } + + override suspend fun deleteCandidate(subscriptionId: String, id: String) { + candidateRows.removeAll { it.subscriptionId == subscriptionId && it.id == id } + } + + override suspend fun getRanges(episodeId: String) = rangeRows.filter { it.episodeId == episodeId } + + override suspend fun upsertRange(range: EpisodeMidrollEntity) { + rangeRows.removeAll { it.episodeId == range.episodeId && it.candidateId == range.candidateId } + rangeRows += range + } + + override suspend fun getUncheckedDownloaded(subscriptionId: String, downloaded: DownloadState) = + episodeRows.filter { + it.subscriptionId == subscriptionId && it.downloadState == downloaded && it.midrollCheckedAtMs == null + }.sortedBy { it.downloadedAtMs ?: 0 } + + override suspend fun setMidrollCheckedAtMs(id: String, ms: Long) { + val index = episodeRows.indexOfFirst { it.id == id } + if (index >= 0) episodeRows[index] = episodeRows[index].copy(midrollCheckedAtMs = ms) + } +} + class FakeQueueDao : QueueDao { val entries = mutableListOf<QueueEntity>() var episodeSource: () -> List<EpisodeEntity> = { emptyList() } diff --git a/app/src/test/java/org/terst/listen/fingerprint/DecimateTest.kt b/app/src/test/java/org/terst/listen/fingerprint/DecimateTest.kt new file mode 100644 index 0000000..c23d5de --- /dev/null +++ b/app/src/test/java/org/terst/listen/fingerprint/DecimateTest.kt @@ -0,0 +1,35 @@ +package org.terst.listen.fingerprint + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class DecimateTest { + + @Test + fun `averages fixed-size blocks`() { + val input = doubleArrayOf(0.0, 4.0, 2.0, 6.0, 10.0, 2.0) + val out = Decimate.byAveraging(input, factor = 2) + assertArrayEquals(doubleArrayOf(2.0, 4.0, 6.0), out, 1e-9) + } + + @Test + fun `a trailing partial block is dropped, not padded`() { + val input = doubleArrayOf(0.0, 4.0, 2.0, 6.0, 10.0) + val out = Decimate.byAveraging(input, factor = 2) + assertArrayEquals(doubleArrayOf(2.0, 4.0), out, 1e-9) + } + + @Test + fun `factor 1 is the identity`() { + val input = doubleArrayOf(1.0, 2.0, 3.0) + assertArrayEquals(input, Decimate.byAveraging(input, factor = 1), 1e-9) + } + + @Test + fun `factor below 1 is rejected`() { + assertThrows(IllegalArgumentException::class.java) { + Decimate.byAveraging(doubleArrayOf(1.0), factor = 0) + } + } +} diff --git a/app/src/test/java/org/terst/listen/fingerprint/FingerprintUpdaterTest.kt b/app/src/test/java/org/terst/listen/fingerprint/FingerprintUpdaterTest.kt index e9cf5c3..4881f88 100644 --- a/app/src/test/java/org/terst/listen/fingerprint/FingerprintUpdaterTest.kt +++ b/app/src/test/java/org/terst/listen/fingerprint/FingerprintUpdaterTest.kt @@ -125,5 +125,7 @@ class FingerprintUpdaterTest { override suspend fun decodeTail(episode: EpisodeEntity): DecodedClip? = tails[episode.id]?.let { DecodedClip(it, sampleRate = 8_000, startMs = tailStartMs) } + + override suspend fun decodeFull(episode: EpisodeEntity): DecodedClip? = null } } diff --git a/app/src/test/java/org/terst/listen/fingerprint/MidrollDetectorTest.kt b/app/src/test/java/org/terst/listen/fingerprint/MidrollDetectorTest.kt new file mode 100644 index 0000000..70ead6c --- /dev/null +++ b/app/src/test/java/org/terst/listen/fingerprint/MidrollDetectorTest.kt @@ -0,0 +1,87 @@ +package org.terst.listen.fingerprint + +import kotlin.random.Random +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class MidrollDetectorTest { + + private fun noise(seed: Int, n: Int) = Random(seed).let { r -> DoubleArray(n) { r.nextDouble(-50.0, 50.0) } } + + private fun embed(base: DoubleArray, segment: DoubleArray, at: Int): DoubleArray { + val out = base.copyOf() + segment.forEachIndexed { i, v -> out[at + i] = v } + return out + } + + private val ad = noise(seed = 7, n = 60) + + @Test + fun `a candidate that matches reaches the threshold and is reported detected`() { + val candidate = MidrollDetector.Candidate(id = "c1", referenceClip = ad, confirmedCount = 2) + val episode = embed(noise(1, 500), ad, at = 200) + + val result = MidrollDetector.check(listOf(candidate), "e3", episode) + + val survivor = result.candidates.single() + assertEquals(3, survivor.confirmedCount) + assertEquals("e3", survivor.lastConfirmedEpisodeId) + val detected = result.detected.single() + assertEquals("c1", detected.candidateId) + assertTrue("expected position near 200, was ${detected.positionInClip}", detected.positionInClip in 190..210) + assertEquals(ad.size, detected.lengthSamples) + } + + @Test + fun `a candidate below threshold after matching is tentative, not detected`() { + val candidate = MidrollDetector.Candidate(id = "c1", referenceClip = ad, confirmedCount = 1) + val episode = embed(noise(1, 500), ad, at = 200) + + val result = MidrollDetector.check(listOf(candidate), "e2", episode) + + assertEquals(2, result.candidates.single().confirmedCount) + assertTrue(result.detected.isEmpty()) + // Not confirmed, but MidrollUpdater still needs to know *where* it matched this episode + // -- to recognise a fresh discovery hit at the same spot as this same, still-tentative + // candidate rather than a genuinely different one. + val match = result.allMatches.single() + assertEquals("c1", match.candidateId) + assertTrue(match.positionInClip in 190..210) + } + + @Test + fun `a candidate that does not hold up is discarded, not weakened`() { + val candidate = MidrollDetector.Candidate(id = "c1", referenceClip = ad, confirmedCount = 2) + val unrelated = noise(99, 500) // the Darknet Diaries case, generalised to midroll + + val result = MidrollDetector.check(listOf(candidate), "e3", unrelated) + + assertTrue(result.candidates.isEmpty()) + assertTrue(result.detected.isEmpty()) + } + + @Test + fun `several candidates are each judged independently in the same run`() { + val good = MidrollDetector.Candidate(id = "good", referenceClip = ad, confirmedCount = 2) + val badAd = noise(seed = 8, n = 60) + val bad = MidrollDetector.Candidate(id = "bad", referenceClip = badAd, confirmedCount = 2) + val episode = embed(noise(1, 500), ad, at = 300) // only `ad` actually appears + + val result = MidrollDetector.check(listOf(good, bad), "e3", episode) + + assertEquals(listOf("good"), result.candidates.map { it.id }) + assertEquals(listOf("good"), result.detected.map { it.candidateId }) + } + + @Test + fun `reprocessing the same episode id is a no-op, not a double count`() { + val candidate = MidrollDetector.Candidate(id = "c1", referenceClip = ad, confirmedCount = 3, lastConfirmedEpisodeId = "e3") + val episode = embed(noise(1, 500), ad, at = 200) + + val result = MidrollDetector.check(listOf(candidate), "e3", episode) + + assertEquals(3, result.candidates.single().confirmedCount) + assertTrue("already-processed episodes are not re-detected", result.detected.isEmpty()) + } +} diff --git a/app/src/test/java/org/terst/listen/fingerprint/MidrollScannerTest.kt b/app/src/test/java/org/terst/listen/fingerprint/MidrollScannerTest.kt new file mode 100644 index 0000000..9a0b091 --- /dev/null +++ b/app/src/test/java/org/terst/listen/fingerprint/MidrollScannerTest.kt @@ -0,0 +1,68 @@ +package org.terst.listen.fingerprint + +import kotlin.random.Random +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Windows and recordings here are deliberately large (hundreds, not tens, of samples): a short + * window's null-distribution of "best correlation somewhere in a few hundred random offsets" has + * high enough variance to clear 0.6 by pure chance, which was measured empirically before sizing + * these fixtures. Real audio never has this problem -- these sizes exist only so the tests mean + * what they say. + */ +class MidrollScannerTest { + + private fun noise(seed: Int, n: Int) = Random(seed).let { r -> DoubleArray(n) { r.nextDouble(-50.0, 50.0) } } + + private fun embed(base: DoubleArray, segment: DoubleArray, at: Int): DoubleArray { + val out = base.copyOf() + segment.forEachIndexed { i, v -> out[at + i] = v } + return out + } + + private val window = 150 + private val step = 75 + private val minScore = 0.6 + + @Test + fun `finds a shared segment at different positions in each recording`() { + val ad = noise(seed = 99, n = 220) + val a = embed(noise(1, 3000), ad, at = 500) + val b = embed(noise(2, 3000), ad, at = 1800) + + val hits = MidrollScanner.findSharedSegments(a, b, window, step, minScore) + + assertEquals(1, hits.size) + val hit = hits.single() + assertTrue("expected positionInA near 500, was ${hit.positionInA}", hit.positionInA in 350..650) + assertTrue("expected positionInB near 1800, was ${hit.positionInB}", hit.positionInB in 1650..1950) + assertTrue("expected a strong score, was ${hit.score}", hit.score > 0.8) + } + + @Test + fun `two unrelated recordings share nothing`() { + val a = noise(1, 3000) + val b = noise(2, 3000) + + val hits = MidrollScanner.findSharedSegments(a, b, window, step, minScore) + + assertTrue(hits.isEmpty()) + } + + @Test + fun `two distinct shared segments are reported separately, not merged`() { + val first = noise(101, 220) + val second = noise(202, 220) + val a = embed(embed(noise(1, 3000), first, at = 200), second, at = 2000) + val b = embed(embed(noise(2, 3000), first, at = 1000), second, at = 2600) + + val hits = MidrollScanner.findSharedSegments(a, b, window, step, minScore) + + assertEquals(2, hits.size) + val diagonals = hits.map { it.positionInA - it.positionInB }.sorted() + // 200 - 1000 = -800, 2000 - 2600 = -600 -- distinct alignments, not one merged run. + assertTrue("diagonals should be well separated, was $diagonals", diagonals[1] - diagonals[0] > step) + } +} diff --git a/app/src/test/java/org/terst/listen/fingerprint/MidrollUpdaterTest.kt b/app/src/test/java/org/terst/listen/fingerprint/MidrollUpdaterTest.kt new file mode 100644 index 0000000..16e4c0d --- /dev/null +++ b/app/src/test/java/org/terst/listen/fingerprint/MidrollUpdaterTest.kt @@ -0,0 +1,182 @@ +package org.terst.listen.fingerprint + +import kotlin.random.Random +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.terst.listen.data.DownloadState +import org.terst.listen.data.EpisodeEntity +import org.terst.listen.data.FakeEpisodeDao +import org.terst.listen.data.FakeMidrollDao +import org.terst.listen.data.FakeSubscriptionDao +import org.terst.listen.data.SubscriptionEntity + +/** + * Windows here match [MidrollScannerTest]'s sizes (150/75/3000-sample recordings), for the same + * reason: a smaller scale has enough null-distribution variance that random noise clears the + * score threshold by chance, which was measured empirically before choosing these numbers. + */ +class MidrollUpdaterTest { + + private lateinit var episodes: FakeEpisodeDao + private lateinit var subscriptions: FakeSubscriptionDao + private lateinit var midrolls: FakeMidrollDao + private lateinit var clips: FakeFullClipSource + private lateinit var updater: MidrollUpdater + + private val fixedNow = 555_000L + + @Before + fun setUp() { + episodes = FakeEpisodeDao() + subscriptions = FakeSubscriptionDao() + midrolls = FakeMidrollDao().apply { episodeRows = episodes.rows } + clips = FakeFullClipSource() + updater = MidrollUpdater( + subscriptions, + episodes, + midrolls, + clips, + // No decimation and a MidrollScannerTest-scale window: the coarse-to-fine split is + // MidrollUpdater's own concern to compose correctly, not something this test needs + // to exercise at real (8kHz-vs-1kHz) scale on top of everything else. + MidrollUpdater.Tuning(coarseFactor = 1, discoverySamples = 150, now = { fixedNow }), + ) + } + + private fun noise(seed: Int, n: Int) = Random(seed).let { r -> DoubleArray(n) { r.nextDouble(-50.0, 50.0) } } + + private fun embed(base: DoubleArray, segment: DoubleArray, at: Int): DoubleArray { + val out = base.copyOf() + segment.forEachIndexed { i, v -> out[at + i] = v } + return out + } + + private fun show(id: String = "show1") { + subscriptions.rows += SubscriptionEntity(id = id, feedUrl = "https://feed/$id", title = id, addedAtMs = 0) + } + + private fun downloadedEpisode(id: String, downloadedAtMs: Long, durationMs: Long = 600_000): EpisodeEntity { + val ep = EpisodeEntity( + id = id, subscriptionId = "show1", guid = id, title = "Episode $id", + mediaUrl = "https://cdn/$id.mp3", mediaType = "audio/mpeg", discoveredAtMs = 0, + localPath = "/fake/$id.mp3", durationMs = durationMs, + downloadState = DownloadState.DOWNLOADED, downloadedAtMs = downloadedAtMs, + ) + episodes.rows += ep + return ep + } + + private val ad = noise(seed = 7, n = 220) + + @Test + fun `nothing to do when no episode is unchecked`() = runTest { + show() + updater.processShow("show1") + assertTrue(midrolls.candidateRows.isEmpty()) + } + + @Test + fun `the production tuning defaults construct a working updater`() = runTest { + show() + val defaultUpdater = MidrollUpdater(subscriptions, episodes, midrolls, clips) + defaultUpdater.processShow("show1") + assertTrue(midrolls.candidateRows.isEmpty()) + } + + @Test + fun `the first downloaded episode only becomes the pending basis`() = runTest { + show() + clips.full["e1"] = embed(noise(1, 3000), ad, at = 500) + downloadedEpisode("e1", downloadedAtMs = 1) + + updater.processShow("show1") + + assertTrue(midrolls.candidateRows.isEmpty()) + assertEquals("e1", subscriptions.rows.single().midrollPendingEpisodeId) + assertEquals(fixedNow, episodes.rows.single { it.id == "e1" }.midrollCheckedAtMs) + } + + @Test + fun `a second episode sharing a segment with the pending one seeds a tentative candidate`() = runTest { + show() + clips.full["e1"] = embed(noise(1, 3000), ad, at = 500) + clips.full["e2"] = embed(noise(2, 3000), ad, at = 1800) + downloadedEpisode("e1", downloadedAtMs = 1) + downloadedEpisode("e2", downloadedAtMs = 2) + + updater.processShow("show1") + updater.processShow("show1") + + val candidate = midrolls.candidateRows.single() + assertEquals(2, candidate.confirmedCount) + assertEquals("e2", candidate.lastConfirmedEpisodeId) + } + + @Test + fun `a third independent episode confirms the candidate and records a skip range`() = runTest { + show() + clips.full["e1"] = embed(noise(1, 3000), ad, at = 500) + clips.full["e2"] = embed(noise(2, 3000), ad, at = 1800) + clips.full["e3"] = embed(noise(3, 3000), ad, at = 1000) + downloadedEpisode("e1", downloadedAtMs = 1) + downloadedEpisode("e2", downloadedAtMs = 2) + downloadedEpisode("e3", downloadedAtMs = 3) + + updater.processShow("show1") + updater.processShow("show1") + updater.processShow("show1") + + val candidate = midrolls.candidateRows.single() + assertEquals(3, candidate.confirmedCount) + + val range = midrolls.rangeRows.single { it.episodeId == "e3" } + val expectedStartMs = 1000L * 1000 / clips.sampleRate + val toleranceMs = 300L * 1000 / clips.sampleRate + assertTrue( + "expected startMs near $expectedStartMs, was ${range.startMs}", + range.startMs in (expectedStartMs - toleranceMs)..(expectedStartMs + toleranceMs), + ) + assertTrue(range.endMs > range.startMs) + } + + @Test + fun `an episode lacking the confirmed segment discards it instead of weakening it`() = runTest { + show() + clips.full["e1"] = embed(noise(1, 3000), ad, at = 500) + clips.full["e2"] = embed(noise(2, 3000), ad, at = 1800) + clips.full["e3"] = noise(99, 3000) // the Darknet Diaries case, generalised to midroll + downloadedEpisode("e1", downloadedAtMs = 1) + downloadedEpisode("e2", downloadedAtMs = 2) + downloadedEpisode("e3", downloadedAtMs = 3) + + updater.processShow("show1") + updater.processShow("show1") + updater.processShow("show1") + + assertTrue("the false candidate must be discarded", midrolls.candidateRows.isEmpty()) + assertTrue(midrolls.rangeRows.isEmpty()) + } + + @Test + fun `an episode that fails to decode is marked checked so it is not retried forever`() = runTest { + show() + downloadedEpisode("e1", downloadedAtMs = 1) // no clip seeded -- FakeFullClipSource returns null + + updater.processShow("show1") + + assertEquals(fixedNow, episodes.rows.single().midrollCheckedAtMs) + } + + private class FakeFullClipSource : AudioClipSource { + val full = mutableMapOf<String, DoubleArray>() + val sampleRate = 8_000 + + override suspend fun decodeHead(episode: EpisodeEntity): DecodedClip? = null + override suspend fun decodeTail(episode: EpisodeEntity): DecodedClip? = null + override suspend fun decodeFull(episode: EpisodeEntity): DecodedClip? = + full[episode.id]?.let { DecodedClip(it, sampleRate, startMs = 0) } + } +} diff --git a/app/src/test/java/org/terst/listen/playback/MidrollSkipTest.kt b/app/src/test/java/org/terst/listen/playback/MidrollSkipTest.kt new file mode 100644 index 0000000..9e8aea3 --- /dev/null +++ b/app/src/test/java/org/terst/listen/playback/MidrollSkipTest.kt @@ -0,0 +1,46 @@ +package org.terst.listen.playback + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.terst.listen.data.EpisodeMidrollEntity + +class MidrollSkipTest { + + private fun range(startMs: Long, endMs: Long) = EpisodeMidrollEntity("e1", "c1", startMs, endMs) + + @Test + fun `no ranges means nothing to skip`() { + assertNull(MidrollSkip.skipTarget(emptyList(), positionMs = 5_000)) + } + + @Test + fun `before a range does nothing`() { + val ranges = listOf(range(60_000, 90_000)) + assertNull(MidrollSkip.skipTarget(ranges, positionMs = 10_000)) + } + + @Test + fun `inside a range jumps to its end`() { + val ranges = listOf(range(60_000, 90_000)) + assertEquals(90_000L, MidrollSkip.skipTarget(ranges, positionMs = 75_000)) + } + + @Test + fun `exactly at the start of a range jumps to its end`() { + val ranges = listOf(range(60_000, 90_000)) + assertEquals(90_000L, MidrollSkip.skipTarget(ranges, positionMs = 60_000)) + } + + @Test + fun `after a range does nothing`() { + val ranges = listOf(range(60_000, 90_000)) + assertNull(MidrollSkip.skipTarget(ranges, positionMs = 90_000)) + } + + @Test + fun `the right one of several non-overlapping ranges is picked`() { + val ranges = listOf(range(60_000, 90_000), range(600_000, 630_000)) + assertEquals(630_000L, MidrollSkip.skipTarget(ranges, positionMs = 610_000)) + } +} diff --git a/app/src/test/java/org/terst/listen/playback/PlaybackAdvancerTest.kt b/app/src/test/java/org/terst/listen/playback/PlaybackAdvancerTest.kt index 171114d..a3b5b0d 100644 --- a/app/src/test/java/org/terst/listen/playback/PlaybackAdvancerTest.kt +++ b/app/src/test/java/org/terst/listen/playback/PlaybackAdvancerTest.kt @@ -7,6 +7,7 @@ import org.junit.Assert.assertNull import org.junit.Before import org.junit.Test import org.terst.listen.data.EpisodeEntity +import org.terst.listen.data.EpisodeMidrollEntity import org.terst.listen.data.FakeEpisodeDao import org.terst.listen.data.FakeQueueDao import org.terst.listen.data.FakeSubscriptionDao @@ -103,6 +104,8 @@ class PlaybackAdvancerTest { return chapters } + override suspend fun midrolls(episode: EpisodeEntity) = emptyList<EpisodeMidrollEntity>() + override suspend fun fetchAudio(episode: EpisodeEntity, kind: SubscriptionKind) = onFetch(episode) } diff --git a/app/src/test/java/org/terst/listen/playback/PlayheadPolicyTest.kt b/app/src/test/java/org/terst/listen/playback/PlayheadPolicyTest.kt index 92ab8fe..a7ddf77 100644 --- a/app/src/test/java/org/terst/listen/playback/PlayheadPolicyTest.kt +++ b/app/src/test/java/org/terst/listen/playback/PlayheadPolicyTest.kt @@ -2,6 +2,7 @@ package org.terst.listen.playback import org.junit.Assert.assertEquals import org.junit.Test +import org.terst.listen.data.EpisodeMidrollEntity import org.terst.listen.data.SubscriptionEntity import org.terst.listen.feed.Chapter @@ -32,9 +33,10 @@ class PlayheadPolicyTest { private fun decide( show: SubscriptionEntity? = this.show, chapters: List<Chapter> = emptyList(), + midrolls: List<EpisodeMidrollEntity> = emptyList(), positionMs: Long, durationMs: Long? = 30 * minute, - ) = PlayheadPolicy.decide(PlayingContext(show, chapters), positionMs, durationMs, progress) + ) = PlayheadPolicy.decide(PlayingContext(show, chapters, midrolls = midrolls), positionMs, durationMs, progress) @Test fun `mid-episode, with nothing configured, there is nothing to do`() { @@ -89,6 +91,26 @@ class PlayheadPolicyTest { ) } + @Test + fun `inside a detected midroll break it seeks past it`() { + val midrolls = listOf(EpisodeMidrollEntity("e1", "c1", 12 * minute, 13 * minute)) + assertEquals( + TickAction.SeekTo(13 * minute), + decide(midrolls = midrolls, positionMs = 12 * minute + 20_000), + ) + } + + /** Unlike ad-chapter skipping there is no per-show opt-out -- same as intro/outro, Tier 3's + * confirmation threshold is the safety mechanism, not an extra toggle nobody would flip. */ + @Test + fun `a detected midroll break is skipped even though no toggle was set`() { + val midrolls = listOf(EpisodeMidrollEntity("e1", "c1", 12 * minute, 13 * minute)) + assertEquals( + TickAction.SeekTo(13 * minute), + decide(show = show.copy(skipAdChapters = false), midrolls = midrolls, positionMs = 12 * minute), + ) + } + /** * Finishing beats skipping. * @@ -111,4 +133,18 @@ class PlayheadPolicyTest { ), ) } + + /** Same precedence as a marked break sitting on the finish line, for the same reason. */ + @Test + fun `the outro wins when a detected midroll break sits on the finish line`() { + val midrolls = listOf(EpisodeMidrollEntity("e1", "c1", 18 * minute, 22 * minute)) + assertEquals( + TickAction.Finish, + decide( + show = show.copy(skipOutroMs = 10 * minute), + midrolls = midrolls, + positionMs = 19 * minute + 40_000, + ), + ) + } } |
