summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-10-05 09:54:59 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-10-05 09:54:59 +0000
commit217c982403a105e986fb7ce13b922d9f08583632 (patch)
tree38f2d3a9556b3dfa1b835b9b2c1f7efcb547e719
parent7cc88394458e9d03e3a6a6e58cdff9c8dd4ad7a3 (diff)
Move skip detection to a server, and find that it detects ads
The server is Peter's design and it works: listend polls the phone's feed list, mirrors the newest 8 episodes per show, detects across all of them at once, and serves the offsets *and the audio they belong to*. Deployed on tungsten behind Apache at https://listen.terst.org. FingerprintWorker and MidrollWorker are deleted -- the battery drain is gone because the work is. Serving the audio is load-bearing. Dynamic ad insertion turned out to be on every episode: Grass Apes declares 22:05 and the server was served 28:18. An offset measured on one copy is minutes wrong for another, so the phone has to play the bytes the detector examined. The offsets land in the same columns the on-device detector wrote, so playback needed no changes and there is no migration. **The headline finding is a failure, and it is proved rather than suspected.** Detection reported the intro confirmed on 8 of 8 Radiolab episodes and passed its self-check. It had locked onto an advertisement. The chosen segment was searched for in an ad-free copy of the same episode fetched hours earlier: r = 0.0621. It is in the ad break, not the programme. The confirmation threshold assumes three *independently fetched* episodes. Mirroring 8 within one minute from the same ad server makes them maximally dependent -- whatever campaign is running is in all of them, identical -- and SelfCheck cannot catch it, because the segments genuinely are the same audio. Moving to a server fixed the cost problem and broke the independence the safety mechanism rests on. The published offsets are head-window matches, so they happen to skip pre-roll advertising, which is useful by accident and will move whenever the campaign rotates. It is not intro detection and is not described as such anywhere. The fix, not made: record when each episode was fetched and require the agreeing episodes to span more than one fetch occasion. Reasoning, and an argument for detecting the ads directly instead, in docs/detection-findings.md. Also recorded there: two tooling traps that each cost real time. `android-ui tap` can land on the soft keyboard, because the dump reports layout coordinates the IME does not move -- a Save button and a text field both appeared tapped and neither fired. And the server's token is alphanumeric now because `adb shell input text` ate the leading "-" of a base64url one and produced a silent 401 that looked like broken wiring. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
-rw-r--r--.coverage-baseline2
-rw-r--r--CLAUDE.md40
-rw-r--r--app/src/main/java/org/terst/listen/data/Preferences.kt18
-rw-r--r--app/src/main/java/org/terst/listen/data/Repositories.kt36
-rw-r--r--app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt52
-rw-r--r--app/src/main/java/org/terst/listen/download/EpisodeDownloader.kt60
-rw-r--r--app/src/main/java/org/terst/listen/server/OkHttpSkipTransport.kt50
-rw-r--r--app/src/main/java/org/terst/listen/server/ServerSkips.kt56
-rw-r--r--app/src/main/java/org/terst/listen/server/SkipServer.kt144
-rw-r--r--app/src/main/java/org/terst/listen/ui/MainActivity.kt27
-rw-r--r--app/src/main/java/org/terst/listen/ui/SettingsScreen.kt60
-rw-r--r--app/src/main/java/org/terst/listen/ui/SkipServerSettingsState.kt35
-rw-r--r--app/src/main/java/org/terst/listen/work/Workers.kt80
-rw-r--r--app/src/test/java/org/terst/listen/data/SubscriptionRepositoryTest.kt8
-rw-r--r--app/src/test/java/org/terst/listen/download/EpisodeDownloaderTest.kt123
-rw-r--r--app/src/test/java/org/terst/listen/server/OkHttpSkipTransportTest.kt113
-rw-r--r--app/src/test/java/org/terst/listen/server/ServerSkipsTest.kt155
-rw-r--r--app/src/test/java/org/terst/listen/server/SkipServerTest.kt194
-rw-r--r--docs/detection-findings.md74
-rw-r--r--server/cmd/listend/main.go411
-rw-r--r--server/cmd/listend/token.go36
-rw-r--r--server/internal/api/api.go198
-rw-r--r--server/internal/api/api_test.go325
-rw-r--r--server/internal/feed/feed.go143
-rw-r--r--server/internal/feed/feed_test.go145
-rw-r--r--server/internal/mirror/mirror.go156
-rw-r--r--server/internal/mirror/mirror_test.go162
-rw-r--r--server/internal/store/id.go20
-rw-r--r--server/internal/store/store.go295
-rw-r--r--server/internal/store/store_test.go239
30 files changed, 3341 insertions, 116 deletions
diff --git a/.coverage-baseline b/.coverage-baseline
index 423c6ac..6e6545f 100644
--- a/.coverage-baseline
+++ b/.coverage-baseline
@@ -1 +1 @@
-93.41
+93.64
diff --git a/CLAUDE.md b/CLAUDE.md
index 5a7d1ae..807a35c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -266,6 +266,46 @@ by default and correct; it is simply waiting for a publisher who marks breaks.
seeks approximate on a VBR file, and the playback position is the crown jewel. Full numbers in
`docs/detection-findings.md`.
+- **Detection runs on a server now, and `server/` is part of this repo.** Go, deployed on
+ tungsten as `listend.service`, behind Apache at https://listen.terst.org with a Let's Encrypt
+ cert. Data in `/site/listen.terst.org/data` (`state.json`, `audio/`, `token`). It polls the
+ phone's feed list, mirrors the newest 8 episodes per show, detects across all of them at once,
+ and serves the offsets *and the audio they belong to*. `FingerprintWorker` and `MidrollWorker`
+ are gone from the app.
+
+ **Serving the audio is load-bearing, not a convenience.** Dynamic ad insertion is on every
+ episode: Radiolab's Grass Apes declares 22:05 and the server was served 28:18. An offset
+ measured on one copy is minutes wrong for another, so the phone has to play the bytes the
+ detector examined. `EpisodeDownloader` prefers the server and falls back to the publisher --
+ the fallback matters, because skips improve playback rather than being a precondition for it.
+
+ **The offsets go into the same columns the on-device detector wrote**, which is why playback
+ needed no changes: `effectiveIntroMs`, `effectiveOutroMs` and `PlayheadPolicy` already read
+ them. No migration either.
+
+- **The confirmation threshold does not survive simultaneous mirroring, and currently detects
+ ads rather than intros.** Proved, not suspected: the segment confirmed on 8 of 8 Radiolab
+ episodes was searched for in an ad-free copy of the same episode fetched hours earlier and
+ scored **r = 0.0621**. It is in the ad break, not the programme. Mirroring 8 episodes within
+ one minute from the same ad server makes them maximally dependent, so whatever campaign is
+ running is in all of them, identical -- and `SelfCheck` cannot catch it, because the segments
+ genuinely *are* the same audio. Do not describe the current offsets as intro detection. The
+ fix is to record fetch times and require the agreeing episodes to span more than one fetch
+ occasion; full reasoning in `docs/detection-findings.md`.
+
+- **`android-ui tap` can land on the soft keyboard.** The dump reports the layout's own
+ coordinates, which the IME does not move, so an element listed at y=1643 can be underneath the
+ keyboard -- taps on it silently do nothing, which reads exactly like a broken handler. Found
+ the hard way: a Save button and a text field both appeared tapped and neither fired. Check
+ `adb shell dumpsys input_method | grep mInputShown` and dismiss with `input keyevent
+ KEYCODE_BACK` (not `KEYCODE_ESCAPE`, which does not close it) before tapping anything low on a
+ screen that has a text field.
+
+- **The server's API token is alphanumeric on purpose.** It is typed into a phone by hand, and
+ the first generated token was base64url -- whose leading `-` was eaten by `adb shell input
+ text` treating it as a flag, producing a silent 401 that looked like broken wiring. The
+ alphabet also omits l, I, 1, O and 0.
+
- **Android Auto is verified in a real car**, as of 2026-09-28. It appears, and connects. The
notification Peter saw on first connect was Android Auto's own new-media-app announcement,
not Listen: the only notification this app can raise is the Media3 transport control, which
diff --git a/app/src/main/java/org/terst/listen/data/Preferences.kt b/app/src/main/java/org/terst/listen/data/Preferences.kt
index 1f7e14f..0c170a1 100644
--- a/app/src/main/java/org/terst/listen/data/Preferences.kt
+++ b/app/src/main/java/org/terst/listen/data/Preferences.kt
@@ -2,6 +2,7 @@ package org.terst.listen.data
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
@@ -31,7 +32,24 @@ class Preferences(private val context: Context) {
context.store.edit { it[SHOW_PLAYED] = value }
}
+ /**
+ * Where the skip server is, and the token for it. Blank means not configured, which is a
+ * working state: the app downloads from publishers and plays without detected skips,
+ * exactly as it did before the server existed.
+ */
+ val serverUrl: Flow<String> = context.store.data.map { it[SERVER_URL] ?: "" }
+ val serverToken: Flow<String> = context.store.data.map { it[SERVER_TOKEN] ?: "" }
+
+ suspend fun setServer(url: String, token: String) {
+ context.store.edit {
+ it[SERVER_URL] = url.trim()
+ it[SERVER_TOKEN] = token.trim()
+ }
+ }
+
private companion object {
val SHOW_PLAYED = booleanPreferencesKey("show_played")
+ val SERVER_URL = stringPreferencesKey("server_url")
+ val SERVER_TOKEN = stringPreferencesKey("server_token")
}
}
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 1b174cf..e425ccf 100644
--- a/app/src/main/java/org/terst/listen/data/Repositories.kt
+++ b/app/src/main/java/org/terst/listen/data/Repositories.kt
@@ -3,7 +3,12 @@ package org.terst.listen.data
import android.content.Context
import coil3.ImageLoader
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
+import kotlinx.coroutines.flow.first
import okhttp3.OkHttpClient
+import org.terst.listen.server.OkHttpSkipTransport
+import org.terst.listen.server.ServerSkips
+import org.terst.listen.server.SkipServer
+import org.terst.listen.server.SkipServerConfig
import org.terst.listen.download.ChaquopyAudioSource
import org.terst.listen.download.EpisodeDownloader
import org.terst.listen.feed.ChapterFetcher
@@ -96,8 +101,30 @@ class AppGraph(private val context: Context) {
subscriptions,
episodes,
FeedFetcher(client),
- onDiscovered = { ruleApplier.apply(it) },
- probe = { url -> ChaquopyAudioSource(context).probe(url) },
+ SubscriptionCollaborators(
+ onDiscovered = { ruleApplier.apply(it) },
+ probe = { url -> ChaquopyAudioSource(context).probe(url) },
+ onRefreshed = { feedUrls -> skipServer.pushSubscriptions(feedUrls) },
+ ),
+ )
+ }
+
+ /**
+ * The skip server, which is where detection happens now.
+ *
+ * It moved off the phone because it could never work here: comparing several episodes'
+ * audio at once needs more memory than an Android process gets and more time than
+ * WorkManager's ten-minute ceiling allows, and the attempt burned the battery for weeks
+ * while confirming nothing.
+ */
+ val skipServer: SkipServer by lazy {
+ SkipServer(
+ config = {
+ val url = preferences.serverUrl.first()
+ val token = preferences.serverToken.first()
+ if (url.isBlank() || token.isBlank()) null else SkipServerConfig(url, token)
+ },
+ transport = OkHttpSkipTransport(client),
)
}
@@ -107,10 +134,7 @@ class AppGraph(private val context: Context) {
episodes,
libraryDir,
ChaquopyAudioSource(context),
- // Fingerprinting needs the file on disk to decode, so it can only start once a
- // download actually lands -- one job per episode rather than a periodic sweep,
- // since the file is right there and there is nothing to gain by waiting.
- onDownloaded = { episode -> Scheduling.fingerprintNow(context, episode.id) },
+ serverAudio = ServerSkips(skipServer, subscriptions, fingerprints, playbackState),
)
}
diff --git a/app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt b/app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt
index 9b94c75..a66441a 100644
--- a/app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt
+++ b/app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt
@@ -30,26 +30,43 @@ private const val SAVED_TITLE = "Saved videos"
private const val SAVED_FACEBOOK_FEED_URL = "listen:saved:facebook"
private const val SAVED_FACEBOOK_TITLE = "Saved Facebook videos"
-class SubscriptionRepository(
- private val subscriptions: SubscriptionDao,
- private val episodes: EpisodeDao,
- private val fetcher: FeedFetcher,
+/**
+ * Everything [SubscriptionRepository] tells the rest of the app about, bundled.
+ *
+ * Grouped rather than passed one by one so the constructor stays a readable, fixed size as more
+ * of the app comes to care what a refresh found -- the same reasoning as [RuleEngine]'s and
+ * [org.terst.listen.fingerprint.MidrollUpdater]'s own tuning values.
+ */
+data class SubscriptionCollaborators(
/**
* Told about episodes a refresh has just discovered, so the auto-queue rules can act on them.
*
- * A collaborator rather than a call into the queue, so this class never learns what a queue
- * is. Deliberately not invoked by [subscribe] or [addSingleEpisode]: see their comments.
+ * Deliberately not invoked by `subscribe` or `addSingleEpisode`: see their comments.
*/
- private val onDiscovered: suspend (List<EpisodeEntity>) -> Unit = {},
+ val onDiscovered: suspend (List<EpisodeEntity>) -> Unit = {},
/**
* Asks the extractor what a video is, for the cases where its page will not say.
*
* Facebook serves no `og:` tags to a non-browser at all, so a shared video would otherwise
* be filed as "Video 10153231379946729". yt-dlp knows, and is going to be run against the
- * URL anyway. A collaborator rather than a direct call because it reaches the embedded
- * Python, which cannot run in a unit test.
+ * URL anyway.
+ */
+ val probe: suspend (String) -> ProbedVideo? = { null },
+ /**
+ * Told the subscription list on every refresh, so the skip server knows which feeds to
+ * mirror and analyse.
+ *
+ * Fired from `refreshAll`, the choke point every refresh route passes through -- including
+ * the library's own refresh button, which does not go via a worker.
*/
- private val probe: suspend (String) -> ProbedVideo? = { null },
+ val onRefreshed: suspend (List<String>) -> Unit = {},
+)
+
+class SubscriptionRepository(
+ private val subscriptions: SubscriptionDao,
+ private val episodes: EpisodeDao,
+ private val fetcher: FeedFetcher,
+ private val collaborators: SubscriptionCollaborators = SubscriptionCollaborators(),
private val now: () -> Long = System::currentTimeMillis,
) {
@@ -108,8 +125,15 @@ class SubscriptionRepository(
}
}
- suspend fun refreshAll(): Map<String, RefreshOutcome> =
- subscriptions.getAll().associate { it.id to refresh(it) }
+ suspend fun refreshAll(): Map<String, RefreshOutcome> {
+ val shows = subscriptions.getAll()
+ // The one place every refresh passes through, which is why the server is told here
+ // rather than in a worker: the refresh button in the library calls this directly and
+ // would otherwise never announce a new subscription. Failure is ignored on purpose --
+ // the skip server improves playback rather than being a precondition for it.
+ collaborators.onRefreshed(shows.map { it.feedUrl })
+ return shows.associate { it.id to refresh(it) }
+ }
/**
* Pulls a show's back catalogue by walking its RFC 5005 `prev-archive` chain.
@@ -156,7 +180,7 @@ class SubscriptionRepository(
val result = EpisodeMatcher.match(subscriptionId, stored, feed.episodes, now())
if (result.inserts.isNotEmpty()) episodes.insertAll(result.inserts)
if (result.updates.isNotEmpty()) episodes.updateAll(result.updates)
- if (report && result.inserts.isNotEmpty()) onDiscovered(result.inserts)
+ if (report && result.inserts.isNotEmpty()) collaborators.onDiscovered(result.inserts)
return RefreshOutcome.Updated(result.inserts.size, result.updates.size)
}
@@ -192,7 +216,7 @@ class SubscriptionRepository(
// Only when the page gave nothing: a title that was shared is the one that was meant,
// and this costs a network round trip.
- val probed = if (video.title.isNullOrBlank()) probe(video.watchUrl) else null
+ val probed = if (video.title.isNullOrBlank()) collaborators.probe(video.watchUrl) else null
val guid = if (facebook) "fb:video:${video.videoId}" else "yt:video:${video.videoId}"
val existing = episodes.getByGuid(container.id, guid)
diff --git a/app/src/main/java/org/terst/listen/download/EpisodeDownloader.kt b/app/src/main/java/org/terst/listen/download/EpisodeDownloader.kt
index ed8bf70..32180c4 100644
--- a/app/src/main/java/org/terst/listen/download/EpisodeDownloader.kt
+++ b/app/src/main/java/org/terst/listen/download/EpisodeDownloader.kt
@@ -9,6 +9,7 @@ import org.terst.listen.data.EpisodeDao
import org.terst.listen.data.EpisodeEntity
import org.terst.listen.data.ProbedVideo
import org.terst.listen.data.SubscriptionKind
+import org.terst.listen.server.ServerEpisode
import java.io.File
import java.io.IOException
@@ -20,18 +21,35 @@ import java.io.IOException
* play with no indication why -- so the kind decides the route, and there is no fallback
* between them.
*/
+/**
+ * The skip server's part in a download: where the audio should come from, and what to write
+ * down once it has arrived.
+ *
+ * Behind an interface because the decision belongs to the download path while the HTTP and the
+ * database writes do not.
+ */
+interface ServerAudio {
+ /** What the server has for this episode, or null to download from the publisher. */
+ suspend fun resolve(episode: EpisodeEntity): ServerEpisode?
+
+ /** Records the server's offsets, once its audio is actually the file on disk. */
+ suspend fun record(episode: EpisodeEntity, info: ServerEpisode)
+}
+
class EpisodeDownloader(
private val client: OkHttpClient,
private val episodes: EpisodeDao,
private val libraryDir: File,
private val extractor: ExtractedAudioSource? = null,
/**
- * Fired once a download actually lands on disk -- the trigger for Tier 3 fingerprint
- * matching, which needs the file present to decode. Every download route ends up here
- * regardless of whether it was a manual tap or a queued/rule-triggered batch, so this is
- * the one place that guarantees every newly downloaded episode gets checked.
+ * The skip server, preferred over the publisher when it has the episode.
+ *
+ * Not an optimisation. A show using dynamic ad insertion serves a different file on every
+ * request -- one real episode measured 22:05 on one fetch and 28:18 on another -- so an
+ * offset computed on the server's copy points at the wrong moment in any other copy.
+ * Playing the bytes the detector examined is the only thing that makes the offsets true.
*/
- private val onDownloaded: suspend (EpisodeEntity) -> Unit = {},
+ private val serverAudio: ServerAudio? = null,
private val now: () -> Long = System::currentTimeMillis,
) {
@@ -47,13 +65,15 @@ class EpisodeDownloader(
SubscriptionKind.FACEBOOK_VIDEO,
-> downloadViaExtractor(episode)
- SubscriptionKind.PODCAST -> downloadOverHttp(episode)
+ SubscriptionKind.PODCAST -> downloadPodcast(episode)
}
result.fold(
+ // Nothing is triggered by a completed download any more. It used to start Tier 3
+ // fingerprinting, which has moved to the server -- where comparing several
+ // episodes at once is possible at all.
onSuccess = {
episodes.updateDownload(episode.id, DownloadState.DOWNLOADED, it.absolutePath, now())
- onDownloaded(episode.copy(downloadState = DownloadState.DOWNLOADED, localPath = it.absolutePath))
},
// localPath is cleared: a FAILED row pointing at a path invites something downstream
// to try playing a file that is not there.
@@ -72,13 +92,35 @@ class EpisodeDownloader(
return File(path).takeIf { it.isFile && it.length() > 0 }
}
+ /**
+ * Prefers the skip server, falling back to the publisher.
+ *
+ * The fallback is deliberate and matters: skips improve playback rather than being a
+ * precondition for it, so a server that is down or mid-fetch must not turn a reachable
+ * episode into a failed download. Offsets are recorded only when the server's audio is
+ * actually what landed on disk.
+ */
+ private suspend fun downloadPodcast(episode: EpisodeEntity): Result<File> {
+ val fromServer = serverAudio?.resolve(episode)
+ val serverUrl = fromServer?.audioUrl?.takeIf { fromServer.ready }
+
+ if (serverUrl != null) {
+ val result = downloadOverHttp(episode, serverUrl)
+ if (result.isSuccess) {
+ serverAudio?.record(episode, fromServer)
+ return result
+ }
+ }
+ return downloadOverHttp(episode, episode.mediaUrl)
+ }
+
private suspend fun downloadViaExtractor(episode: EpisodeEntity): Result<File> {
val source = extractor
?: return Result.failure(IllegalStateException("No audio extractor configured"))
return runCatching { source.download(episode.mediaUrl, libraryDir) }
}
- private suspend fun downloadOverHttp(episode: EpisodeEntity): Result<File> =
+ private suspend fun downloadOverHttp(episode: EpisodeEntity, url: String): Result<File> =
withContext(Dispatchers.IO) {
val target = targetFile(episode)
// Written to a partial name and renamed on success. A truncated file recorded as
@@ -87,7 +129,7 @@ class EpisodeDownloader(
partial.parentFile?.mkdirs()
try {
- val request = Request.Builder().url(episode.mediaUrl).header("User-Agent", USER_AGENT).build()
+ val request = Request.Builder().url(url).header("User-Agent", USER_AGENT).build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
partial.delete()
diff --git a/app/src/main/java/org/terst/listen/server/OkHttpSkipTransport.kt b/app/src/main/java/org/terst/listen/server/OkHttpSkipTransport.kt
new file mode 100644
index 0000000..1541dfb
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/server/OkHttpSkipTransport.kt
@@ -0,0 +1,50 @@
+package org.terst.listen.server
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+
+/**
+ * [SkipTransport] over the app's own OkHttp client.
+ *
+ * On the framework-adapter exclusion list: there is no decision in here, only the HTTP that
+ * [SkipServer]'s logic is deliberately kept free of.
+ *
+ * Every failure is a null or a false rather than an exception. The server is an improvement the
+ * app prefers, not something it depends on -- so unreachable, slow and broken all have to mean
+ * the same thing to the caller as not configured: carry on without it.
+ */
+class OkHttpSkipTransport(private val client: OkHttpClient) : SkipTransport {
+
+ override suspend fun get(url: String, token: String): String? = withContext(Dispatchers.IO) {
+ runCatching {
+ val request = Request.Builder()
+ .url(url)
+ .header("Authorization", "Bearer $token")
+ .build()
+ client.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) return@use null
+ response.body?.string()
+ }
+ }.getOrNull()
+ }
+
+ override suspend fun post(url: String, token: String, body: String): Boolean =
+ withContext(Dispatchers.IO) {
+ runCatching {
+ val request = Request.Builder()
+ .url(url)
+ .header("Authorization", "Bearer $token")
+ .post(body.toRequestBody(JSON))
+ .build()
+ client.newCall(request).execute().use { it.isSuccessful }
+ }.getOrDefault(false)
+ }
+
+ private companion object {
+ val JSON = "application/json; charset=utf-8".toMediaType()
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/server/ServerSkips.kt b/app/src/main/java/org/terst/listen/server/ServerSkips.kt
new file mode 100644
index 0000000..edff77b
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/server/ServerSkips.kt
@@ -0,0 +1,56 @@
+package org.terst.listen.server
+
+import org.terst.listen.data.EpisodeEntity
+import org.terst.listen.data.PlaybackStateDao
+import org.terst.listen.data.ShowFingerprintDao
+import org.terst.listen.data.SubscriptionDao
+import org.terst.listen.download.ServerAudio
+
+/**
+ * Connects the skip server to the download path.
+ *
+ * What the server returns goes into exactly the columns the on-device detector used to write,
+ * which is why playback needed no changes at all: `effectiveIntroMs`, `effectiveOutroMs` and
+ * `PlayheadPolicy` already read them. Only the origin of the numbers has moved.
+ */
+class ServerSkips(
+ private val server: SkipServer,
+ private val subscriptions: SubscriptionDao,
+ private val fingerprints: ShowFingerprintDao,
+ private val playbackState: PlaybackStateDao,
+) : ServerAudio {
+
+ /**
+ * Asks the server about an episode, identifying it the way the server keys them.
+ *
+ * Three identifiers are sent, not one, because publishers rewrite feeds: a GUID that has
+ * moved still matches on its enclosure URL, and failing that on its title. An episode that
+ * loses its identity loses its offsets.
+ */
+ override suspend fun resolve(episode: EpisodeEntity): ServerEpisode? {
+ val feedUrl = subscriptions.getById(episode.subscriptionId)?.feedUrl ?: return null
+ return server.episode(
+ feedUrl = feedUrl,
+ guid = episode.guid,
+ enclosureUrl = episode.mediaUrl,
+ title = episode.title,
+ )
+ }
+
+ /**
+ * Writes down what the server knew, once its audio is the file on disk.
+ *
+ * Each value is written only if the server actually reported it. A missing offset is not a
+ * zero: a detected zero means "this show has no intro", which playback treats differently
+ * from having nothing detected, and collapsing the two would make a real finding
+ * indistinguishable from an absent one.
+ */
+ override suspend fun record(episode: EpisodeEntity, info: ServerEpisode) {
+ info.introMs?.let { fingerprints.setDetectedIntroMs(episode.id, it) }
+ info.outroMs?.let { fingerprints.setDetectedOutroMs(episode.id, it) }
+ // The length the file really is. The feed's figure is minutes short on any show using
+ // dynamic ad insertion, and this is the number that places the outro offset and decides
+ // when an episode counts as finished.
+ info.durationMs?.let { playbackState.updateDuration(episode.id, it) }
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/server/SkipServer.kt b/app/src/main/java/org/terst/listen/server/SkipServer.kt
new file mode 100644
index 0000000..a480fb5
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/server/SkipServer.kt
@@ -0,0 +1,144 @@
+package org.terst.listen.server
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonPrimitive
+
+/**
+ * Where the skip server is and how to authenticate to it.
+ *
+ * Null everywhere it appears means "not configured", which is a working state: the app falls
+ * back to downloading from publishers with no detected skips, exactly as it did before the
+ * server existed.
+ */
+data class SkipServerConfig(val baseUrl: String, val token: String)
+
+/** The HTTP the client needs, behind an interface so its logic is testable off-device. */
+interface SkipTransport {
+ /** Returns the response body, or null for any failure. */
+ suspend fun get(url: String, token: String): String?
+
+ /** Returns whether the post succeeded. */
+ suspend fun post(url: String, token: String, body: String): Boolean
+}
+
+/** What the server knows about one episode. */
+data class ServerEpisode(
+ /** Whether the audio can be played from the server now. */
+ val ready: Boolean,
+ /** An absolute, token-carrying URL, usable by the player as given. */
+ val audioUrl: String?,
+ /** The file's real length, which is routinely not what the feed declared. */
+ val durationMs: Long?,
+ val introMs: Long?,
+ val outroMs: Long?,
+ /**
+ * Whether detection has run over this episode's show. Distinct from having offsets: a show
+ * can be examined and genuinely have no recurring intro, and reporting that as "nothing
+ * found yet" is the mistake the app's own readout used to make.
+ */
+ val detected: Boolean,
+)
+
+/**
+ * The phone's half of the skip server.
+ *
+ * Detection itself moved off the phone. The intro search compares several episodes' audio at
+ * once, which needs more memory than an Android process is given and more time than
+ * WorkManager's ten-minute ceiling allows -- the attempt burned the battery for weeks and
+ * confirmed nothing. What is left here is asking.
+ *
+ * The server serves the audio as well as the offsets, and that is load-bearing rather than
+ * convenient: a show using dynamic ad insertion returns a different file on every request, so
+ * an offset measured on the server's copy points at the wrong moment in a copy the phone
+ * fetched for itself. Measured on one real episode: 22:05 on one fetch, 28:18 on another.
+ * Playing the server's bytes is what makes its numbers true.
+ */
+class SkipServer(
+ private val config: suspend () -> SkipServerConfig?,
+ private val transport: SkipTransport,
+) {
+
+ /**
+ * Tells the server what the phone subscribes to.
+ *
+ * The phone owns this list: everything already built around it -- the share sheet, OPML
+ * import, the queue rules, Android Auto's browse tree -- keeps working untouched, and the
+ * server stays something the app prefers rather than depends on.
+ */
+ suspend fun pushSubscriptions(feedUrls: List<String>): Boolean {
+ val target = config() ?: return false
+ val body = buildString {
+ append("""{"feeds":[""")
+ feedUrls.forEachIndexed { index, url ->
+ if (index > 0) append(",")
+ append(JsonPrimitive(url))
+ }
+ append("]}")
+ }
+ return transport.post(base(target) + "/api/subscriptions", target.token, body)
+ }
+
+ /**
+ * Asks what to skip in one episode, and where its audio is.
+ *
+ * Null whenever there is no answer -- not configured, unreachable, or a response that is not
+ * what this expects. Every one of those means the same thing to the caller: carry on the way
+ * the app worked before the server existed.
+ */
+ suspend fun episode(
+ feedUrl: String,
+ guid: String,
+ enclosureUrl: String,
+ title: String,
+ ): ServerEpisode? {
+ val target = config() ?: return null
+ val query = buildString {
+ append("?feed=").append(encode(feedUrl))
+ append("&guid=").append(encode(guid))
+ append("&enclosure=").append(encode(enclosureUrl))
+ append("&title=").append(encode(title))
+ }
+
+ val body = transport.get(base(target) + "/api/episode" + query, target.token) ?: return null
+ val parsed = runCatching { lenient.decodeFromString<EpisodeResponse>(body) }.getOrNull() ?: return null
+
+ return ServerEpisode(
+ ready = parsed.status == "ready" && parsed.audioUrl != null,
+ audioUrl = parsed.audioUrl?.let { absolute(base(target), it) },
+ durationMs = parsed.durationMs?.takeIf { it > 0 },
+ introMs = parsed.introMs,
+ outroMs = parsed.outroMs,
+ detected = parsed.detected,
+ )
+ }
+
+ private fun base(target: SkipServerConfig) = target.baseUrl.trimEnd('/')
+
+ /**
+ * The server returns a root-relative audio path; a player handed that unresolved would try
+ * to open a local file.
+ */
+ private fun absolute(base: String, path: String): String =
+ if (path.startsWith("http://") || path.startsWith("https://")) path else base + path
+
+ @Serializable
+ private data class EpisodeResponse(
+ val status: String = "",
+ @SerialName("audioUrl") val audioUrl: String? = null,
+ @SerialName("durationMs") val durationMs: Long? = null,
+ @SerialName("introMs") val introMs: Long? = null,
+ @SerialName("outroMs") val outroMs: Long? = null,
+ val detected: Boolean = false,
+ )
+
+ private companion object {
+ /** Tolerant of fields this app does not know about, so the server can grow. */
+ val lenient = Json { ignoreUnknownKeys = true; isLenient = true }
+ }
+}
+
+/** Percent-encoding for a query value. A feed URL and a GUID both contain characters that
+ * would otherwise end the parameter early. */
+private fun encode(value: String): String = java.net.URLEncoder.encode(value, "UTF-8")
diff --git a/app/src/main/java/org/terst/listen/ui/MainActivity.kt b/app/src/main/java/org/terst/listen/ui/MainActivity.kt
index a4cc048..771e844 100644
--- a/app/src/main/java/org/terst/listen/ui/MainActivity.kt
+++ b/app/src/main/java/org/terst/listen/ui/MainActivity.kt
@@ -16,6 +16,8 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.navigation.compose.NavHost
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
@@ -108,10 +110,7 @@ private fun ListenApp(
PlayerScreen(player = player, onBack = { nav.popBackStack() })
}
episodeRoute(nav, viewModel, player)
- composable("settings") {
- SettingsScreen(opml = opmlViewModel, player = player,
- onOpenRules = { nav.navigate("rules") }, onBack = { nav.popBackStack() })
- }
+ settingsRoute(nav, opmlViewModel, player)
rulesRoutes(nav, rulesViewModel)
composable("queue") {
QueueScreen(
@@ -216,3 +215,23 @@ private fun androidx.navigation.NavGraphBuilder.rulesRoutes(
}
}
}
+
+/**
+ * The settings route, extracted so the app's own navigation graph stays readable as screens are
+ * added to it.
+ */
+private fun NavGraphBuilder.settingsRoute(
+ nav: NavHostController,
+ opml: OpmlViewModel,
+ player: PlayerViewModel,
+) {
+ composable("settings") {
+ SettingsScreen(
+ opml = opml,
+ player = player,
+ server = rememberSkipServerSettings(),
+ onOpenRules = { nav.navigate("rules") },
+ onBack = { nav.popBackStack() },
+ )
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/ui/SettingsScreen.kt b/app/src/main/java/org/terst/listen/ui/SettingsScreen.kt
index 1d8da1b..9afa3e9 100644
--- a/app/src/main/java/org/terst/listen/ui/SettingsScreen.kt
+++ b/app/src/main/java/org/terst/listen/ui/SettingsScreen.kt
@@ -15,11 +15,12 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
-import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarHost
@@ -30,7 +31,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@@ -47,6 +50,7 @@ private val OPML_MIME_TYPES = arrayOf("text/x-opml", "text/xml", "application/xm
fun SettingsScreen(
opml: OpmlViewModel,
player: PlayerViewModel,
+ server: SkipServerSettings,
onOpenRules: () -> Unit,
onBack: () -> Unit,
) {
@@ -103,10 +107,64 @@ fun SettingsScreen(
onImport = { importLauncher.launch(OPML_MIME_TYPES) },
onExport = { exportLauncher.launch("listen-subscriptions.opml") },
)
+ HorizontalDivider()
+ SkipServerSection(server)
}
}
}
+/** The skip server's address and token, as the settings screen reads and writes them. */
+data class SkipServerSettings(
+ val url: String,
+ val token: String,
+ val onSave: (url: String, token: String) -> Unit,
+)
+
+/**
+ * Where the skip detection comes from.
+ *
+ * Detection used to run on the phone and could not work there: comparing several episodes'
+ * audio at once needs more memory than an Android process gets and more time than WorkManager
+ * allows, so it burned the battery for weeks and confirmed nothing. The server does that work
+ * and serves the audio it examined -- which is the part that matters, because a show using
+ * dynamic ad insertion returns a different file on every request and an offset measured on one
+ * copy is wrong for another.
+ *
+ * Left blank the app behaves as it did before: downloads from publishers, no detected skips.
+ */
+@Composable
+private fun SkipServerSection(settings: SkipServerSettings) {
+ var url by remember(settings.url) { mutableStateOf(settings.url) }
+ var token by remember(settings.token) { mutableStateOf(settings.token) }
+
+ Section("Skip server") {
+ OutlinedTextField(
+ value = url,
+ onValueChange = { url = it },
+ label = { Text("Address") },
+ placeholder = { Text("https://listen.terst.org") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ OutlinedTextField(
+ value = token,
+ onValueChange = { token = it },
+ label = { Text("Token") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Button(onClick = { settings.onSave(url, token) }) { Text("Save") }
+ Text(
+ if (settings.url.isBlank()) {
+ "Not set. Episodes download from publishers and play without detected skips."
+ } else {
+ "Episodes download from the server, which is also what analysed them."
+ },
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+}
+
@Composable
private fun SpeedSection(current: Float, onSelect: (Float) -> Unit) {
Section("Playback speed") {
diff --git a/app/src/main/java/org/terst/listen/ui/SkipServerSettingsState.kt b/app/src/main/java/org/terst/listen/ui/SkipServerSettingsState.kt
new file mode 100644
index 0000000..fc0e49d
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/ui/SkipServerSettingsState.kt
@@ -0,0 +1,35 @@
+package org.terst.listen.ui
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.platform.LocalContext
+import kotlinx.coroutines.launch
+import org.terst.listen.data.Graph
+
+/**
+ * Reads and writes the skip server's address and token.
+ *
+ * Trivial glue, kept out of the composable that renders them so the screen stays a screen. In
+ * DataStore rather than Room, like the other settings that describe how the app is being used
+ * rather than what is in the library.
+ */
+@Composable
+internal fun rememberSkipServerSettings(): SkipServerSettings {
+ val context = LocalContext.current
+ val preferences = remember(context) { Graph.get(context).preferences }
+ val scope = rememberCoroutineScope()
+
+ val url by preferences.serverUrl.collectAsState(initial = "")
+ val token by preferences.serverToken.collectAsState(initial = "")
+
+ return SkipServerSettings(
+ url = url,
+ token = token,
+ onSave = { newUrl, newToken ->
+ scope.launch { preferences.setServer(newUrl, newToken) }
+ },
+ )
+}
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 9c48db0..9809e9d 100644
--- a/app/src/main/java/org/terst/listen/work/Workers.kt
+++ b/app/src/main/java/org/terst/listen/work/Workers.kt
@@ -12,9 +12,6 @@ import androidx.work.WorkManager
import androidx.work.WorkerParameters
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
/**
@@ -28,6 +25,9 @@ class RefreshWorker(context: Context, params: WorkerParameters) : CoroutineWorke
override suspend fun doWork(): Result {
val graph = Graph.get(applicationContext)
+ // The subscription list reaches the skip server from SubscriptionRepository.refreshAll,
+ // which every refresh route goes through -- including the library's refresh button,
+ // which does not come via this worker.
val outcomes = graph.repository.refreshAll()
val failed = outcomes.values.count { it is RefreshOutcome.Failed }
// After the refresh, so a drip release can pick up anything that just arrived.
@@ -77,62 +77,15 @@ class RetentionWorker(context: Context, params: WorkerParameters) : CoroutineWor
}
}
-/**
- * Checks one newly downloaded episode against its show's Tier 3 fingerprint state.
- *
- * The decode is [org.terst.listen.fingerprint.MediaCodecClipSource] (no JVM equivalent); the
- * decision is [org.terst.listen.fingerprint.FingerprintUpdater], which is tested.
- */
-class FingerprintWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
-
- override suspend fun doWork(): Result {
- val episodeId = inputData.getString(KEY_EPISODE_ID) ?: return Result.failure()
- val graph = Graph.get(applicationContext)
- val episode = graph.episodes.getById(episodeId) ?: return Result.success()
-
- FingerprintUpdater(graph.fingerprints, graph.episodes, MediaCodecClipSource()).process(episode)
-
- return Result.success()
- }
-
- companion object {
- const val KEY_EPISODE_ID = "episodeId"
- }
-}
-
-/**
- * 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(),
- graph.midrollClips,
- )
- 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.
@@ -150,20 +103,6 @@ object Scheduling {
}
/**
- * Checks one freshly downloaded episode against its show's fingerprint state.
- *
- * Not a unique work name: unlike a burst of queueing, a run of downloads is one episode
- * each, and there is no "latest one wins" -- every episode needs its own check.
- */
- fun fingerprintNow(context: Context, episodeId: String) {
- WorkManager.getInstance(context).enqueue(
- OneTimeWorkRequestBuilder<FingerprintWorker>()
- .setInputData(androidx.work.Data.Builder().putString(FingerprintWorker.KEY_EPISODE_ID, episodeId).build())
- .build(),
- )
- }
-
- /**
* The periodic jobs are KEEP rather than UPDATE, so the schedule survives an app restart
* instead of resetting its interval every launch -- which on a frequently-opened app means
* the periodic work never actually fires.
@@ -208,18 +147,5 @@ object Scheduling {
.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/SubscriptionRepositoryTest.kt b/app/src/test/java/org/terst/listen/data/SubscriptionRepositoryTest.kt
index ccede65..2474f8a 100644
--- a/app/src/test/java/org/terst/listen/data/SubscriptionRepositoryTest.kt
+++ b/app/src/test/java/org/terst/listen/data/SubscriptionRepositoryTest.kt
@@ -349,7 +349,7 @@ class SubscriptionRepositoryTest {
subs,
eps,
FeedFetcher(OkHttpClient()),
- probe = { ProbedVideo(title = "Sheep dog at work", durationMs = 92_000) },
+ SubscriptionCollaborators(probe = { ProbedVideo(title = "Sheep dog at work", durationMs = 92_000) }),
) { clock }
probing.addSingleEpisode(video("111", null, kind = SubscriptionKind.FACEBOOK_VIDEO))
@@ -366,7 +366,7 @@ class SubscriptionRepositoryTest {
subs,
eps,
FeedFetcher(OkHttpClient()),
- probe = { ProbedVideo(title = "Something else", durationMs = 1) },
+ SubscriptionCollaborators(probe = { ProbedVideo(title = "Something else", durationMs = 1) }),
) { clock }
probing.addSingleEpisode(video("aaa", "From the page"))
@@ -381,7 +381,7 @@ class SubscriptionRepositoryTest {
subs,
eps,
FeedFetcher(OkHttpClient()),
- probe = { null },
+ SubscriptionCollaborators(probe = { null }),
) { clock }
probing.addSingleEpisode(video("111", null, kind = SubscriptionKind.FACEBOOK_VIDEO))
@@ -403,7 +403,7 @@ class SubscriptionRepositoryTest {
subs,
eps,
FeedFetcher(OkHttpClient()),
- onDiscovered = { discovered += it },
+ SubscriptionCollaborators(onDiscovered = { discovered += it }),
) { clock }
@Test
diff --git a/app/src/test/java/org/terst/listen/download/EpisodeDownloaderTest.kt b/app/src/test/java/org/terst/listen/download/EpisodeDownloaderTest.kt
index d5e971b..b35983a 100644
--- a/app/src/test/java/org/terst/listen/download/EpisodeDownloaderTest.kt
+++ b/app/src/test/java/org/terst/listen/download/EpisodeDownloaderTest.kt
@@ -17,6 +17,7 @@ import org.terst.listen.data.DownloadState
import org.terst.listen.data.EpisodeEntity
import org.terst.listen.data.FakeEpisodeDao
import org.terst.listen.data.SubscriptionKind
+import org.terst.listen.server.ServerEpisode
import java.io.File
class EpisodeDownloaderTest {
@@ -161,4 +162,126 @@ class EpisodeDownloaderTest {
downloader.download(ep, SubscriptionKind.YOUTUBE_CHANNEL)
assertEquals("no plain HTTP request should have been made", 0, server.requestCount)
}
+
+ // ---- the skip server as the preferred source ----
+ //
+ // The server has to serve the audio, not just the offsets. A show using dynamic ad
+ // insertion returns a different file on every request -- one real episode measured 22:05 on
+ // one fetch and 28:18 on another -- so an offset computed on the server's copy is simply
+ // wrong for a copy the phone fetched itself. Downloading from the server is what makes its
+ // numbers apply to the bytes being played.
+
+ private class FakeServerAudio(private val info: ServerEpisode?) : ServerAudio {
+ var recorded: Pair<String, ServerEpisode>? = null
+ override suspend fun resolve(episode: EpisodeEntity) = info
+ override suspend fun record(episode: EpisodeEntity, info: ServerEpisode) {
+ recorded = episode.id to info
+ }
+ }
+
+ private fun withServer(audio: ServerAudio?) =
+ EpisodeDownloader(OkHttpClient(), dao, library, null, audio) { 1_000L }
+
+ @Test
+ fun `downloads from the skip server when it has the episode`() = runTest {
+ server.enqueue(MockResponse().setBody("server-bytes"))
+ val audio = FakeServerAudio(
+ ServerEpisode(
+ ready = true,
+ audioUrl = server.url("/audio/abc?token=t").toString(),
+ durationMs = 1_698_717,
+ introMs = 142_000,
+ outroMs = 11_000,
+ detected = true,
+ )
+ )
+ val ep = episode(url = "https://publisher.example/original.mp3").also { dao.rows += it }
+
+ val result = withServer(audio).download(ep, SubscriptionKind.PODCAST)
+
+ assertTrue(result.isSuccess)
+ assertEquals("server-bytes", result.getOrThrow().readText())
+ assertEquals("/audio/abc?token=t", server.takeRequest().path)
+ }
+
+ @Test
+ fun `records what the server knew once the file is on disk`() = runTest {
+ server.enqueue(MockResponse().setBody("server-bytes"))
+ val info = ServerEpisode(
+ ready = true,
+ audioUrl = server.url("/audio/abc").toString(),
+ durationMs = 1_698_717,
+ introMs = 142_000,
+ outroMs = 11_000,
+ detected = true,
+ )
+ val audio = FakeServerAudio(info)
+ val ep = episode(url = "https://publisher.example/original.mp3").also { dao.rows += it }
+
+ withServer(audio).download(ep, SubscriptionKind.PODCAST)
+
+ assertEquals(ep.id to info, audio.recorded)
+ }
+
+ @Test
+ fun `downloads from the publisher when the server has nothing yet`() = runTest {
+ server.enqueue(MockResponse().setBody("publisher-bytes"))
+ val ep = episode(url = server.url("/original.mp3").toString()).also { dao.rows += it }
+
+ val result = withServer(FakeServerAudio(null)).download(ep, SubscriptionKind.PODCAST)
+
+ assertTrue(result.isSuccess)
+ assertEquals("/original.mp3", server.takeRequest().path)
+ }
+
+ @Test
+ fun `records nothing when the server had nothing`() = runTest {
+ server.enqueue(MockResponse().setBody("publisher-bytes"))
+ val audio = FakeServerAudio(null)
+ val ep = episode(url = server.url("/original.mp3").toString()).also { dao.rows += it }
+
+ withServer(audio).download(ep, SubscriptionKind.PODCAST)
+
+ assertNull(audio.recorded)
+ }
+
+ /**
+ * The server going down must not cost Peter the episode. Skips are an improvement on
+ * playback, not a precondition for it -- and a dependency that turns a reachable publisher
+ * into a failed download would make the app worse than before the server existed.
+ */
+ @Test
+ fun `falls back to the publisher when the server's own audio fails`() = runTest {
+ server.enqueue(MockResponse().setResponseCode(502))
+ server.enqueue(MockResponse().setBody("publisher-bytes"))
+ val audio = FakeServerAudio(
+ ServerEpisode(ready = true, audioUrl = server.url("/audio/broken").toString(),
+ durationMs = null, introMs = 1, outroMs = 2, detected = true)
+ )
+ val ep = episode(url = server.url("/original.mp3").toString()).also { dao.rows += it }
+
+ val result = withServer(audio).download(ep, SubscriptionKind.PODCAST)
+
+ assertTrue("expected the publisher fallback to succeed", result.isSuccess)
+ assertEquals("publisher-bytes", result.getOrThrow().readText())
+ assertEquals("/audio/broken", server.takeRequest().path)
+ assertEquals("/original.mp3", server.takeRequest().path)
+ }
+
+ /** Offsets describe the server's copy, so they must not be recorded against a file that
+ * came from the publisher instead. */
+ @Test
+ fun `records nothing when the audio came from the publisher after all`() = runTest {
+ server.enqueue(MockResponse().setResponseCode(502))
+ server.enqueue(MockResponse().setBody("publisher-bytes"))
+ val audio = FakeServerAudio(
+ ServerEpisode(ready = true, audioUrl = server.url("/audio/broken").toString(),
+ durationMs = null, introMs = 1, outroMs = 2, detected = true)
+ )
+ val ep = episode(url = server.url("/original.mp3").toString()).also { dao.rows += it }
+
+ withServer(audio).download(ep, SubscriptionKind.PODCAST)
+
+ assertNull("recorded offsets for audio it did not download", audio.recorded)
+ }
}
diff --git a/app/src/test/java/org/terst/listen/server/OkHttpSkipTransportTest.kt b/app/src/test/java/org/terst/listen/server/OkHttpSkipTransportTest.kt
new file mode 100644
index 0000000..335b68d
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/server/OkHttpSkipTransportTest.kt
@@ -0,0 +1,113 @@
+package org.terst.listen.server
+
+import kotlinx.coroutines.test.runTest
+import okhttp3.OkHttpClient
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+/**
+ * The transport's whole contract is that nothing it does can break the app.
+ *
+ * The skip server is an improvement on playback, not a precondition for it: unreachable, slow,
+ * misconfigured and returning nonsense all have to mean the same thing to the caller as "not
+ * configured" -- carry on downloading from publishers. An exception escaping from here would
+ * turn a reachable episode into a failed download, which is worse than never having had a
+ * server at all.
+ */
+class OkHttpSkipTransportTest {
+
+ private lateinit var server: MockWebServer
+ private lateinit var transport: OkHttpSkipTransport
+
+ @Before
+ fun setUp() {
+ server = MockWebServer()
+ server.start()
+ transport = OkHttpSkipTransport(OkHttpClient())
+ }
+
+ @After
+ fun tearDown() = server.shutdown()
+
+ @Test
+ fun `returns the body of a successful get`() = runTest {
+ server.enqueue(MockResponse().setBody("""{"status":"ready"}"""))
+
+ val got = transport.get(server.url("/api/episode").toString(), "tok")
+
+ assertEquals("""{"status":"ready"}""", got)
+ }
+
+ @Test
+ fun `sends the token as a bearer header`() = runTest {
+ server.enqueue(MockResponse().setBody("{}"))
+
+ transport.get(server.url("/api/episode").toString(), "hunter2")
+
+ assertEquals("Bearer hunter2", server.takeRequest().getHeader("Authorization"))
+ }
+
+ @Test
+ fun `an unauthorised response is nothing rather than an error`() = runTest {
+ server.enqueue(MockResponse().setResponseCode(401).setBody("""{"error":"unauthorized"}"""))
+
+ assertNull(transport.get(server.url("/api/episode").toString(), "wrong"))
+ }
+
+ @Test
+ fun `a server error is nothing rather than an error`() = runTest {
+ server.enqueue(MockResponse().setResponseCode(502))
+
+ assertNull(transport.get(server.url("/api/episode").toString(), "tok"))
+ }
+
+ /** A misconfigured address must not throw out of the download path. */
+ @Test
+ fun `an unreachable host is nothing rather than an error`() = runTest {
+ assertNull(transport.get("http://127.0.0.1:1/api/episode", "tok"))
+ }
+
+ @Test
+ fun `a malformed url is nothing rather than an error`() = runTest {
+ assertNull(transport.get("not a url at all", "tok"))
+ }
+
+ @Test
+ fun `posts the body as json`() = runTest {
+ server.enqueue(MockResponse().setResponseCode(200))
+
+ val ok = transport.post(
+ server.url("/api/subscriptions").toString(),
+ "tok",
+ """{"feeds":["https://a"]}""",
+ )
+
+ assertTrue(ok)
+ val request = server.takeRequest()
+ assertEquals("POST", request.method)
+ assertEquals("""{"feeds":["https://a"]}""", request.body.readUtf8())
+ assertTrue(
+ "content type was ${request.getHeader("Content-Type")}",
+ request.getHeader("Content-Type")?.startsWith("application/json") == true,
+ )
+ }
+
+ @Test
+ fun `a rejected post reports false`() = runTest {
+ server.enqueue(MockResponse().setResponseCode(400))
+
+ assertFalse(transport.post(server.url("/api/subscriptions").toString(), "tok", "{}"))
+ }
+
+ @Test
+ fun `a post to an unreachable host reports false`() = runTest {
+ assertFalse(transport.post("http://127.0.0.1:1/api/subscriptions", "tok", "{}"))
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/server/ServerSkipsTest.kt b/app/src/test/java/org/terst/listen/server/ServerSkipsTest.kt
new file mode 100644
index 0000000..57c03f3
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/server/ServerSkipsTest.kt
@@ -0,0 +1,155 @@
+package org.terst.listen.server
+
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.terst.listen.data.EpisodeEntity
+import org.terst.listen.data.FakeEpisodeDao
+import org.terst.listen.data.FakePlaybackStateDao
+import org.terst.listen.data.FakeShowFingerprintDao
+import org.terst.listen.data.FakeSubscriptionDao
+import org.terst.listen.data.SubscriptionEntity
+
+/**
+ * What the phone does with the server's answer.
+ *
+ * The offsets land in the same columns the on-device detector used to write, which is why
+ * playback needed no changes at all: `effectiveIntroMs`, `MidrollSkip` and `PlayheadPolicy`
+ * already read exactly these. Only where the numbers come from has changed.
+ */
+class ServerSkipsTest {
+
+ private class StubTransport(private val body: String?) : SkipTransport {
+ val gets = mutableListOf<String>()
+ override suspend fun get(url: String, token: String): String? {
+ gets += url
+ return body
+ }
+
+ override suspend fun post(url: String, token: String, body: String) = true
+ }
+
+ private val show = SubscriptionEntity(
+ id = "s1",
+ feedUrl = "https://feeds.simplecast.com/EmVW7VGp",
+ title = "Radiolab",
+ addedAtMs = 0,
+ )
+
+ private val episode = EpisodeEntity(
+ id = "e1",
+ subscriptionId = "s1",
+ guid = "guid-1",
+ title = "Grass Apes",
+ mediaUrl = "https://cdn.example/grass.mp3",
+ mediaType = "audio/mpeg",
+ discoveredAtMs = 0,
+ )
+
+ private fun subject(
+ transport: SkipTransport,
+ subscriptions: FakeSubscriptionDao = FakeSubscriptionDao().apply { rows += show },
+ episodes: FakeEpisodeDao = FakeEpisodeDao().apply { rows += episode },
+ fingerprints: FakeShowFingerprintDao = FakeShowFingerprintDao(),
+ ): ServerSkips {
+ // Both fakes write through to the same episode rows, the way the real foreign table
+ // does, so a test sees the value where production would put it.
+ fingerprints.episodeRows = episodes.rows
+ return ServerSkips(
+ SkipServer({ SkipServerConfig("https://listen.terst.org", "tok") }, transport),
+ subscriptions,
+ fingerprints,
+ FakePlaybackStateDao(episodes.rows),
+ )
+ }
+
+ /** The server keys episodes by feed, so the show's feed URL has to be looked up and sent. */
+ @Test
+ fun `asks the server using the show's feed url`() = runTest {
+ val transport = StubTransport("""{"status":"pending"}""")
+
+ subject(transport).resolve(episode)
+
+ val url = transport.gets.single()
+ assertTrue("feed url not sent in $url", url.contains("EmVW7VGp"))
+ assertTrue("guid not sent in $url", url.contains("guid-1"))
+ }
+
+ /** An episode whose show has been unsubscribed has no feed to ask about. */
+ @Test
+ fun `does not ask when the show is gone`() = runTest {
+ val transport = StubTransport("""{"status":"pending"}""")
+
+ val got = subject(transport, subscriptions = FakeSubscriptionDao()).resolve(episode)
+
+ assertNull(got)
+ assertTrue("asked about a show that is not subscribed", transport.gets.isEmpty())
+ }
+
+ @Test
+ fun `records the detected offsets against the episode`() = runTest {
+ val episodes = FakeEpisodeDao().apply { rows += episode }
+
+ subject(StubTransport(null), episodes = episodes).record(
+ episode,
+ ServerEpisode(ready = true, audioUrl = "x", durationMs = 1_698_717,
+ introMs = 142_000, outroMs = 11_000, detected = true),
+ )
+
+ val stored = episodes.rows.single { it.id == "e1" }
+ assertEquals(142_000L, stored.detectedIntroMs)
+ assertEquals(11_000L, stored.detectedOutroMs)
+ }
+
+ /**
+ * The measured length too: the feed's claim is minutes short on any show using dynamic ad
+ * insertion, and this length is the one the file actually has.
+ */
+ @Test
+ fun `records the measured duration`() = runTest {
+ val episodes = FakeEpisodeDao().apply { rows += episode }
+
+ subject(StubTransport(null), episodes = episodes).record(
+ episode,
+ ServerEpisode(ready = true, audioUrl = "x", durationMs = 1_698_717,
+ introMs = null, outroMs = null, detected = true),
+ )
+
+ assertEquals(1_698_717L, episodes.rows.single { it.id == "e1" }.actualDurationMs)
+ }
+
+ /**
+ * An absent offset must not be written as zero. Zero is a real detected value meaning "this
+ * show has no intro", and playback distinguishes the two.
+ */
+ @Test
+ fun `leaves an offset the server did not report alone`() = runTest {
+ val episodes = FakeEpisodeDao().apply { rows += episode }
+
+ subject(StubTransport(null), episodes = episodes).record(
+ episode,
+ ServerEpisode(ready = true, audioUrl = "x", durationMs = null,
+ introMs = 142_000, outroMs = null, detected = true),
+ )
+
+ val stored = episodes.rows.single { it.id == "e1" }
+ assertEquals(142_000L, stored.detectedIntroMs)
+ assertNull("wrote an outro the server never reported", stored.detectedOutroMs)
+ }
+
+ /** A detected zero is a finding, not an absence, and has to be stored as one. */
+ @Test
+ fun `records a detected zero offset`() = runTest {
+ val episodes = FakeEpisodeDao().apply { rows += episode }
+
+ subject(StubTransport(null), episodes = episodes).record(
+ episode,
+ ServerEpisode(ready = true, audioUrl = "x", durationMs = null,
+ introMs = 0, outroMs = null, detected = true),
+ )
+
+ assertEquals(0L, episodes.rows.single { it.id == "e1" }.detectedIntroMs)
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/server/SkipServerTest.kt b/app/src/test/java/org/terst/listen/server/SkipServerTest.kt
new file mode 100644
index 0000000..bdf9d6a
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/server/SkipServerTest.kt
@@ -0,0 +1,194 @@
+package org.terst.listen.server
+
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * The phone's half of the skip server.
+ *
+ * Detection does not happen here any more, and for a reason worth keeping written down: the
+ * intro search needs several episodes' audio compared at once, which needs more memory than an
+ * Android process gets and more time than WorkManager allows. The phone's job is now to say
+ * what it subscribes to, ask what to skip, and play the audio the answer belongs to.
+ *
+ * That last part is not optional. A show using dynamic ad insertion serves a different file on
+ * every request, so an offset measured on the server's copy is wrong for a copy the phone
+ * fetched itself -- Grass Apes came back at 22:05 on one fetch and 28:18 on another. Playing
+ * the server's bytes is what makes the numbers apply.
+ */
+class SkipServerTest {
+
+ private class FakeTransport : SkipTransport {
+ var response: String? = null
+ var failPost = false
+ val gets = mutableListOf<String>()
+ val posts = mutableListOf<Pair<String, String>>()
+ var seenToken: String? = null
+
+ override suspend fun get(url: String, token: String): String? {
+ gets += url
+ seenToken = token
+ return response
+ }
+
+ override suspend fun post(url: String, token: String, body: String): Boolean {
+ posts += url to body
+ seenToken = token
+ return !failPost
+ }
+ }
+
+ private fun server(
+ transport: SkipTransport,
+ baseUrl: String? = "https://listen.terst.org",
+ token: String? = "tok",
+ ) = SkipServer({ if (baseUrl == null || token == null) null else SkipServerConfig(baseUrl, token) }, transport)
+
+ private val ready = """
+ {"status":"ready","audioUrl":"/audio/abc123?token=tok","durationMs":1698717,
+ "introMs":142000,"outroMs":11000,"detected":true}
+ """.trimIndent()
+
+ @Test
+ fun `reads what the server says about an episode`() = runTest {
+ val transport = FakeTransport().apply { response = ready }
+
+ val got = server(transport).episode("https://f", "g1", "https://cdn/1.mp3", "One")
+
+ assertTrue("expected a ready episode, got $got", got?.ready == true)
+ assertEquals("https://listen.terst.org/audio/abc123?token=tok", got?.audioUrl)
+ assertEquals(1698717L, got?.durationMs)
+ assertEquals(142000L, got?.introMs)
+ assertEquals(11000L, got?.outroMs)
+ assertTrue(got?.detected == true)
+ }
+
+ /**
+ * The server returns a root-relative audio path. Handing that to a player unresolved would
+ * have it try to open a local file.
+ */
+ @Test
+ fun `resolves a relative audio url against the server`() = runTest {
+ val transport = FakeTransport().apply {
+ response = """{"status":"ready","audioUrl":"/audio/abc?token=tok"}"""
+ }
+
+ val got = server(transport, baseUrl = "https://listen.terst.org/").episode("https://f", "g1", "", "")
+
+ assertEquals("https://listen.terst.org/audio/abc?token=tok", got?.audioUrl)
+ }
+
+ @Test
+ fun `an absolute audio url is left alone`() = runTest {
+ val transport = FakeTransport().apply {
+ response = """{"status":"ready","audioUrl":"https://cdn.example/abc.mp3"}"""
+ }
+
+ val got = server(transport).episode("https://f", "g1", "", "")
+
+ assertEquals("https://cdn.example/abc.mp3", got?.audioUrl)
+ }
+
+ /** Pending is a real answer: the server is fetching it, so the phone should not treat it
+ * as a failure, but it has nothing to play yet either. */
+ @Test
+ fun `a pending episode is reported as not ready`() = runTest {
+ val transport = FakeTransport().apply { response = """{"status":"pending","detected":false}""" }
+
+ val got = server(transport).episode("https://f", "g1", "", "")
+
+ assertEquals(false, got?.ready)
+ assertNull(got?.audioUrl)
+ }
+
+ /** Missing offsets must come back as absent, not as zero: zero is a real detected value
+ * meaning "this show has no intro", and playback treats the two differently. */
+ @Test
+ fun `absent offsets are null rather than zero`() = runTest {
+ val transport = FakeTransport().apply { response = """{"status":"ready","audioUrl":"/audio/a"}""" }
+
+ val got = server(transport).episode("https://f", "g1", "", "")
+
+ assertNull(got?.introMs)
+ assertNull(got?.outroMs)
+ }
+
+ /** A feed URL and a GUID both contain characters that would otherwise break the query. */
+ @Test
+ fun `encodes the feed url and guid into the query`() = runTest {
+ val transport = FakeTransport()
+
+ server(transport).episode(
+ feedUrl = "https://feeds.simplecast.com/EmVW7VGp?x=1&y=2",
+ guid = "tag:example,2026:1 odd",
+ enclosureUrl = "https://cdn/a b.mp3",
+ title = "Grass Apes & Co",
+ )
+
+ val url = transport.gets.single()
+ assertTrue("feed not encoded in $url", url.contains("feed=https%3A%2F%2Ffeeds.simplecast.com%2FEmVW7VGp%3Fx%3D1%26y%3D2"))
+ assertTrue("guid not encoded in $url", url.contains("guid=tag%3Aexample%2C2026%3A1+odd"))
+ assertTrue("ampersand in the title not encoded in $url", url.contains("%26+Co"))
+ }
+
+ @Test
+ fun `sends the token`() = runTest {
+ val transport = FakeTransport().apply { response = ready }
+ server(transport, token = "hunter2").episode("https://f", "g1", "", "")
+ assertEquals("hunter2", transport.seenToken)
+ }
+
+ /** With nothing configured the server is simply not used, and nothing throws. */
+ @Test
+ fun `an unconfigured server is a no-op rather than an error`() = runTest {
+ val transport = FakeTransport()
+
+ assertNull(server(transport, baseUrl = null).episode("https://f", "g1", "", ""))
+ assertEquals(false, server(transport, token = null).pushSubscriptions(listOf("https://f")))
+ assertTrue("talked to a server that is not configured", transport.gets.isEmpty() && transport.posts.isEmpty())
+ }
+
+ @Test
+ fun `rubbish from the server is reported as nothing rather than crashing`() = runTest {
+ val transport = FakeTransport().apply { response = "<html>502 Bad Gateway</html>" }
+
+ assertNull(server(transport).episode("https://f", "g1", "", ""))
+ }
+
+ @Test
+ fun `no response at all is reported as nothing`() = runTest {
+ assertNull(server(FakeTransport()).episode("https://f", "g1", "", ""))
+ }
+
+ @Test
+ fun `pushes the subscription list as json`() = runTest {
+ val transport = FakeTransport()
+
+ val ok = server(transport).pushSubscriptions(listOf("https://a", "https://b"))
+
+ assertTrue(ok)
+ val (url, body) = transport.posts.single()
+ assertEquals("https://listen.terst.org/api/subscriptions", url)
+ assertEquals("""{"feeds":["https://a","https://b"]}""", body)
+ }
+
+ @Test
+ fun `a failed push is reported rather than thrown`() = runTest {
+ val transport = FakeTransport().apply { failPost = true }
+
+ assertEquals(false, server(transport).pushSubscriptions(listOf("https://a")))
+ }
+
+ /** A trailing slash on the configured URL must not produce a double slash in the path. */
+ @Test
+ fun `tolerates a trailing slash on the base url`() = runTest {
+ val transport = FakeTransport()
+
+ server(transport, baseUrl = "https://listen.terst.org/").pushSubscriptions(listOf("https://a"))
+
+ assertEquals("https://listen.terst.org/api/subscriptions", transport.posts.single().first)
+ }
+}
diff --git a/docs/detection-findings.md b/docs/detection-findings.md
index 1bb7880..26b31b3 100644
--- a/docs/detection-findings.md
+++ b/docs/detection-findings.md
@@ -495,3 +495,77 @@ milliseconds, and `SelfCheck` tolerates half a second of position error for the
one host with curl is not enough to conclude either way, and it should be measured properly
before the design leans on it.
- **Midroll detection is not ported.** Only intro/outro.
+
+---
+
+## 2026-10-05, later: the server ships, and simultaneous mirroring defeats the threshold
+
+`server/` now runs on tungsten as `listend.service`, behind Apache at https://listen.terst.org
+with a Let's Encrypt certificate. It polls the phone's subscriptions, mirrors the newest 8
+episodes per show, detects, and serves both the offsets and the audio they belong to. The phone
+pushes its feed list from `SubscriptionRepository.refreshAll`, prefers the server's audio when it
+has it, and writes the offsets into the same `detectedIntroMs`/`detectedOutroMs` columns the
+on-device detector used -- so playback needed no changes at all. `FingerprintWorker` and
+`MidrollWorker` are deleted.
+
+Verified live, phone to server: `POST /api/subscriptions` returned 200 from the app, the server
+woke, mirrored 8 Radiolab episodes, and detected across all of them in 3m59s.
+
+### Dynamic ad insertion is on every episode, and it is not subtle
+
+Every one of the 8 came back longer than declared:
+
+| episode | declared | served | inserted |
+| --- | --- | --- | --- |
+| Grass Apes | 22:05 | 28:18 | 6:13 |
+| The Sweetest Thing | 31:02 | 37:15 | 6:13 |
+| 23 Weeks 6 Days | 63:27 | 72:26 | 8:59 |
+| Nigel and the Real Gannet | 25:54 | 33:01 | 7:07 |
+| The Shoe Thrower | 77:29 | 87:02 | 9:33 |
+
+This also corrects the earlier pass in this document, which found declared == served on four
+fetches made with plain `curl` and doubted whether insertion was happening at all. It is, on
+every episode, when fetched by the server. Why `curl` got clean files and the server does not is
+unexplained and worth knowing -- the only obvious difference is the User-Agent.
+
+### The confirmation threshold does not survive simultaneous fetching
+
+Detection reported the intro confirmed on 8 of 8, self-check passed. **It had locked onto an
+advertisement.**
+
+Proved, rather than suspected: the 8-second segment the detector chose was extracted from the
+server's copy of Grass Apes and searched for in the `curl` copy of the same episode fetched
+hours earlier with no inserted advertising. **r = 0.0621.** It is not in the programme. It is in
+the ad break.
+
+The mechanism is the Darknet Diaries trap at a new scale. The threshold's whole premise is that
+three *independently fetched* episodes are unlikely to share an inserted ad. Mirroring 8
+episodes within the same minute from the same ad server makes them maximally dependent: whatever
+campaign is running is in all 8, identical, and the detector finds it with scores of 0.93 to
+1.00. The self-check cannot catch this -- it only proves the located segments are the same
+audio, and they genuinely are.
+
+This is *worse* than the phone's original design, where episodes arrived days apart and so were
+genuinely independent. Moving to a server fixed the cost problem and broke the independence
+assumption the safety mechanism rests on.
+
+### What the published offsets currently are
+
+Head-window matches only, so every offset lands inside the first four minutes, and skipping to
+the end of a matched segment skips pre-roll advertising. That is useful by accident, not by
+design, and it will move unpredictably whenever the ad campaign rotates. **It is not intro
+detection and must not be described as such.**
+
+### The fix, not yet made
+
+Record when each episode's audio was fetched, and require the agreeing episodes to span more
+than one fetch occasion before a segment is trusted as the show's own. The mirror already rolls
+forward one episode at a time as feeds update, so independence arrives naturally -- it is only
+the initial batch that is simultaneous. Expect no offsets at all for the first day or two after
+a show is added, which is the correct answer rather than a confident wrong one.
+
+There is a reframing worth considering instead, which the data argues for: what this reliably
+detects, with very high confidence, is **the inserted advertising itself** -- because
+simultaneous fetches share it exactly. Skipping ads is the actual goal, and the declared-versus-
+served gap is a free, independent check on whether a scan has accounted for all of it. Detecting
+ads directly may be a better target than detecting intros and hoping ads do not get in the way.
diff --git a/server/cmd/listend/main.go b/server/cmd/listend/main.go
new file mode 100644
index 0000000..588af0d
--- /dev/null
+++ b/server/cmd/listend/main.go
@@ -0,0 +1,411 @@
+// Command listend mirrors a phone's podcast subscriptions, works out what to skip in them, and
+// serves both to the phone.
+//
+// It exists because detection cannot work on the phone. The intro/outro search needs several
+// episodes' audio compared at once, which needs more memory than an Android process gets and
+// more time than WorkManager's ten-minute ceiling allows -- and the attempt burned the battery
+// while confirming nothing. Here the same work is unremarkable.
+//
+// It serves the audio as well as the offsets, which is not a convenience. A show using dynamic
+// ad insertion returns a different file on every request, so an offset measured on one copy
+// points at the wrong moment in another. The phone playing the very bytes the detector examined
+// is what makes the numbers correct.
+package main
+
+import (
+ "context"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "os"
+ "os/signal"
+ "path/filepath"
+ "sync"
+ "syscall"
+ "time"
+
+ "github.com/terst/listen/server/internal/api"
+ "github.com/terst/listen/server/internal/decode"
+ "github.com/terst/listen/server/internal/feed"
+ "github.com/terst/listen/server/internal/mirror"
+ "github.com/terst/listen/server/internal/skips"
+ "github.com/terst/listen/server/internal/store"
+)
+
+func main() {
+ var (
+ addr = flag.String("addr", "127.0.0.1:38090", "address to listen on")
+ dataDir = flag.String("data", "/site/listen.terst.org/data", "where state and audio live")
+ keep = flag.Int("keep", 8, "episodes to mirror per show")
+ pollEvery = flag.Duration("poll", 2*time.Hour, "how often to refresh every feed")
+ tokenFile = flag.String("token-file", "", "file holding the API token (defaults to <data>/token)")
+ once = flag.Bool("once", false, "run one poll cycle and exit, for checking a deployment")
+ )
+ flag.Parse()
+
+ token, err := readToken(*tokenFile, *dataDir)
+ if err != nil {
+ log.Fatalf("token: %v", err)
+ }
+
+ statePath := filepath.Join(*dataDir, "state.json")
+ audioDir := filepath.Join(*dataDir, "audio")
+ state, err := store.Load(statePath)
+ if err != nil {
+ log.Fatalf("state: %v", err)
+ }
+
+ decoder := decode.Decoder{}
+ worker := &worker{
+ state: state,
+ statePath: statePath,
+ keep: *keep,
+ mirror: &mirror.Mirror{AudioDir: audioDir, Probe: decoder},
+ decoder: decoder,
+ requests: make(chan request, 64),
+ wakeups: make(chan struct{}, 1),
+ }
+
+ server := api.New(state, worker, api.Options{
+ Token: token,
+ AudioDir: audioDir,
+ // Act on a new subscription list straight away: the phone sends it on refresh, and
+ // waiting for the next interval would leave a new show with nothing for hours.
+ OnSubscriptions: func([]string) { worker.wake() },
+ })
+
+ ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+ defer stop()
+
+ if *once {
+ worker.pollAll(ctx)
+ if err := state.Save(statePath); err != nil {
+ log.Fatalf("save: %v", err)
+ }
+ log.Printf("one cycle complete")
+ return
+ }
+
+ var wg sync.WaitGroup
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ worker.run(ctx, *pollEvery)
+ }()
+
+ httpServer := &http.Server{
+ Addr: *addr,
+ Handler: server.Handler(),
+ ReadHeaderTimeout: 20 * time.Second,
+ }
+ go func() {
+ <-ctx.Done()
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
+ defer cancel()
+ _ = httpServer.Shutdown(shutdownCtx)
+ }()
+
+ log.Printf("listening on %s, data in %s, keeping %d episodes per show", *addr, *dataDir, *keep)
+ if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ log.Fatalf("http: %v", err)
+ }
+ wg.Wait()
+ if err := state.Save(statePath); err != nil {
+ log.Printf("save on exit: %v", err)
+ }
+}
+
+// request is an episode the phone asked for that the server did not have.
+type request struct {
+ feedURL, guid, enclosureURL, title string
+}
+
+type worker struct {
+ state *store.State
+ statePath string
+ keep int
+ mirror *mirror.Mirror
+ decoder decode.Decoder
+ requests chan request
+ wakeups chan struct{}
+}
+
+// wake asks for a poll now. Coalescing, so a burst of subscription updates is one poll.
+func (w *worker) wake() {
+ select {
+ case w.wakeups <- struct{}{}:
+ default:
+ }
+}
+
+// Request implements api.Fetcher. Non-blocking: a full queue drops the request rather than
+// stalling the phone, and the next poll will pick the episode up anyway.
+func (w *worker) Request(feedURL, guid, enclosureURL, title string) {
+ select {
+ case w.requests <- request{feedURL, guid, enclosureURL, title}:
+ default:
+ log.Printf("request queue full, dropping %s %s", feedURL, guid)
+ }
+}
+
+func (w *worker) run(ctx context.Context, every time.Duration) {
+ w.pollAll(ctx)
+ w.save()
+
+ ticker := time.NewTicker(every)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ w.pollAll(ctx)
+ w.save()
+ case <-w.wakeups:
+ w.pollAll(ctx)
+ w.save()
+ case req := <-w.requests:
+ w.serveRequest(ctx, req)
+ w.save()
+ }
+ }
+}
+
+func (w *worker) save() {
+ if err := w.state.Save(w.statePath); err != nil {
+ log.Printf("save: %v", err)
+ }
+}
+
+// pollAll refreshes every subscribed feed, then mirrors and detects what changed.
+func (w *worker) pollAll(ctx context.Context) {
+ feeds := w.state.Feeds()
+ if len(feeds) == 0 {
+ log.Printf("no subscriptions yet; waiting for the phone to send its list")
+ return
+ }
+ for _, feedURL := range feeds {
+ if ctx.Err() != nil {
+ return
+ }
+ w.pollOne(ctx, feedURL)
+ }
+ // A feed no longer subscribed stops being polled; reclaim its audio too.
+ for _, url := range w.state.SetFeeds(feeds) {
+ if show := w.state.Forget(url); show != nil {
+ for _, e := range show.Episodes {
+ _ = w.mirror.Prune(e)
+ }
+ log.Printf("%s: unsubscribed, reclaimed %d episodes", url, len(show.Episodes))
+ }
+ }
+}
+
+func (w *worker) pollOne(ctx context.Context, feedURL string) {
+ raw, err := fetchFeed(ctx, feedURL)
+ if err != nil {
+ w.noteFeedError(feedURL, err)
+ return
+ }
+ channel, err := feed.Parse(raw)
+ if err != nil {
+ w.noteFeedError(feedURL, err)
+ return
+ }
+
+ show := w.state.Merge(feedURL, channel.Title, channel.Episodes)
+ show.LastPolledUnix = time.Now().Unix()
+ show.LastError = ""
+
+ kept, pruned := show.Partition(w.keep)
+ for _, e := range pruned {
+ if e.Mirrored() {
+ _ = w.mirror.Prune(e)
+ }
+ }
+
+ fetched := 0
+ for _, e := range kept {
+ if ctx.Err() != nil {
+ return
+ }
+ if e.Mirrored() {
+ continue
+ }
+ if err := w.mirror.Fetch(ctx, e); err != nil {
+ log.Printf("%s: %v", channel.Title, err)
+ continue
+ }
+ fetched++
+ }
+
+ if fetched > 0 {
+ log.Printf("%s: mirrored %d new episode(s)", channel.Title, fetched)
+ }
+ w.detect(ctx, show, kept)
+}
+
+// detect runs the intro/outro search over a show's mirrored episodes and records the result.
+//
+// Every mirrored episode at once, which is the whole reason this is a server: the phone could
+// only hold one episode's clip, so it needed a rolling state machine that accumulated agreement
+// over weeks and lost it whenever retention reclaimed the file it was holding.
+func (w *worker) detect(ctx context.Context, show *store.Show, candidates []*store.Episode) {
+ var episodes []skips.Episode
+ byID := map[string]*store.Episode{}
+ for _, e := range candidates {
+ if !e.Mirrored() {
+ continue
+ }
+ episodes = append(episodes, skips.Episode{
+ ID: e.ID,
+ Path: filepath.Join(w.mirror.AudioDir, e.Path),
+ })
+ byID[e.ID] = e
+ }
+ if len(episodes) < skips.DefaultConfirmThreshold {
+ return
+ }
+
+ started := time.Now()
+ result, err := skips.Detect(ctx, w.decoder, episodes, skips.DefaultConfig())
+ if err != nil {
+ log.Printf("%s: detection failed: %v", show.Title, err)
+ return
+ }
+
+ now := time.Now().Unix()
+ applied := 0
+ for _, o := range result.Offsets {
+ e := byID[o.EpisodeID]
+ if e == nil {
+ continue
+ }
+ e.DetectedAtUnix = now
+ e.IntroMs, e.OutroMs = o.IntroMs, o.OutroMs
+ if o.IntroMs != nil || o.OutroMs != nil {
+ applied++
+ }
+ }
+
+ log.Printf("%s: intro on %d/%d%s, outro on %d/%d%s, %d episode(s) carry offsets, took %s",
+ show.Title,
+ result.IntroConfirmedOn, len(episodes), checkNote(result.IntroCheck),
+ result.OutroConfirmedOn, len(episodes), checkNote(result.OutroCheck),
+ applied, time.Since(started).Round(time.Second))
+}
+
+// checkNote reports a withheld result loudly. A confirmation that could not verify itself is
+// the case where publishing offsets would cut the wrong audio.
+func checkNote(r skips.CheckReport) string {
+ if r.Pairs == 0 || r.OK {
+ return ""
+ }
+ return fmt.Sprintf(" (SELF-CHECK FAILED %s vs %s at r=%.3f, offsets withheld)", r.WorstA, r.WorstB, r.WorstScore)
+}
+
+// serveRequest handles an episode the phone wanted that was not mirrored.
+func (w *worker) serveRequest(ctx context.Context, req request) {
+ e := w.state.Match(req.feedURL, req.guid, req.enclosureURL, req.title)
+ if e == nil {
+ // Not in the feed as last polled. Register it from what the phone told us, so an
+ // episode older than the mirrored window is still playable with offsets.
+ if req.enclosureURL == "" {
+ return
+ }
+ w.state.Merge(req.feedURL, "", []feed.Episode{{
+ GUID: req.guid,
+ Title: req.title,
+ EnclosureURL: req.enclosureURL,
+ PublishedUnix: time.Now().Unix(),
+ }})
+ e = w.state.Match(req.feedURL, req.guid, req.enclosureURL, req.title)
+ if e == nil {
+ return
+ }
+ }
+ if e.Mirrored() {
+ return
+ }
+ if err := w.mirror.Fetch(ctx, e); err != nil {
+ log.Printf("on-demand fetch %s: %v", e.ID, err)
+ return
+ }
+ log.Printf("on-demand: mirrored %s", e.Title)
+
+ if show := w.state.Show(req.feedURL); show != nil {
+ kept, _ := show.Partition(w.keep)
+ w.detect(ctx, show, kept)
+ }
+}
+
+func (w *worker) noteFeedError(feedURL string, err error) {
+ log.Printf("%s: %v", feedURL, err)
+ if show := w.state.Show(feedURL); show != nil {
+ show.LastError = err.Error()
+ show.LastPolledUnix = time.Now().Unix()
+ }
+}
+
+func fetchFeed(ctx context.Context, url string) ([]byte, error) {
+ ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
+ defer cancel()
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("User-Agent", "listen-server/1.0 (+https://terst.org)")
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("%s returned %s", url, resp.Status)
+ }
+ // Feeds are large -- a real subscription produced 2753 episodes -- but not unbounded.
+ return io.ReadAll(io.LimitReader(resp.Body, 64<<20))
+}
+
+// readToken loads the API token, generating one on first run.
+func readToken(tokenFile, dataDir string) (string, error) {
+ if tokenFile == "" {
+ tokenFile = filepath.Join(dataDir, "token")
+ }
+ if raw, err := os.ReadFile(tokenFile); err == nil {
+ token := trimSpace(string(raw))
+ if token == "" {
+ return "", fmt.Errorf("%s is empty", tokenFile)
+ }
+ return token, nil
+ } else if !os.IsNotExist(err) {
+ return "", err
+ }
+
+ token, err := generateToken()
+ if err != nil {
+ return "", err
+ }
+ if err := os.MkdirAll(filepath.Dir(tokenFile), 0o755); err != nil {
+ return "", err
+ }
+ if err := os.WriteFile(tokenFile, []byte(token+"\n"), 0o600); err != nil {
+ return "", err
+ }
+ log.Printf("generated a new API token in %s", tokenFile)
+ return token, nil
+}
+
+func trimSpace(s string) string {
+ start, end := 0, len(s)
+ for start < end && (s[start] == ' ' || s[start] == '\n' || s[start] == '\r' || s[start] == '\t') {
+ start++
+ }
+ for end > start && (s[end-1] == ' ' || s[end-1] == '\n' || s[end-1] == '\r' || s[end-1] == '\t') {
+ end--
+ }
+ return s[start:end]
+}
diff --git a/server/cmd/listend/token.go b/server/cmd/listend/token.go
new file mode 100644
index 0000000..c6962a7
--- /dev/null
+++ b/server/cmd/listend/token.go
@@ -0,0 +1,36 @@
+package main
+
+import (
+ "crypto/rand"
+ "math/big"
+ "strings"
+)
+
+// tokenAlphabet is deliberately letters and digits only, with no punctuation.
+//
+// This token is typed by hand into a phone. A base64url token looks stronger and is worse in
+// practice: the leading "-" of one generated token was eaten by `adb shell input text` treating
+// it as a flag, and "-" and "_" are both buried behind a modifier on a phone keyboard. 32
+// characters of alphanumeric is ~165 bits, which is not the weak link here.
+const tokenAlphabet = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
+
+// tokenLength is 32 characters, grouped into fours by the caller when it prints them.
+const tokenLength = 32
+
+// generateToken makes a fresh API token.
+//
+// From crypto/rand: this is the only thing between the open internet and a private library.
+// The alphabet omits l, I, 1, O and 0 so a token read off a screen cannot be mistyped into a
+// different valid-looking one.
+func generateToken() (string, error) {
+ limit := big.NewInt(int64(len(tokenAlphabet)))
+ var out strings.Builder
+ for i := 0; i < tokenLength; i++ {
+ n, err := rand.Int(rand.Reader, limit)
+ if err != nil {
+ return "", err
+ }
+ out.WriteByte(tokenAlphabet[n.Int64()])
+ }
+ return out.String(), nil
+}
diff --git a/server/internal/api/api.go b/server/internal/api/api.go
new file mode 100644
index 0000000..b6f14ff
--- /dev/null
+++ b/server/internal/api/api.go
@@ -0,0 +1,198 @@
+// Package api serves the phone: what to skip, and the audio those offsets belong to.
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/terst/listen/server/internal/store"
+)
+
+// Fetcher is asked to mirror an episode the phone wants and the server does not have.
+//
+// Asynchronous on purpose: the phone gets an immediate "pending" rather than holding a request
+// open for the length of a download.
+type Fetcher interface {
+ Request(feedURL, guid, enclosureURL, title string)
+}
+
+// Options configures the server.
+type Options struct {
+ // Token guards everything but health. This serves a private library over the open
+ // internet.
+ Token string
+ // AudioDir is where mirrored episodes live.
+ AudioDir string
+ // OnSubscriptions is called when the phone sends its list, so a newly-subscribed show is
+ // polled at once rather than at the next interval. Optional.
+ OnSubscriptions func(feeds []string)
+}
+
+// Server is the HTTP surface.
+type Server struct {
+ state *store.State
+ fetcher Fetcher
+ opts Options
+}
+
+// New builds the server.
+func New(state *store.State, fetcher Fetcher, opts Options) *Server {
+ return &Server{state: state, fetcher: fetcher, opts: opts}
+}
+
+type episodeResponse struct {
+ // Status is "ready" when the audio can be played now, or "pending" when the server has
+ // been asked to fetch it. There is deliberately no "unknown": an episode the phone knows
+ // about is one the server can be told to go and get.
+ Status string `json:"status"`
+ AudioURL string `json:"audioUrl,omitempty"`
+ DurationMs int64 `json:"durationMs,omitempty"`
+ IntroMs *int64 `json:"introMs,omitempty"`
+ OutroMs *int64 `json:"outroMs,omitempty"`
+ // Detected says whether detection has run over this episode's show, so the phone can tell
+ // "looked and found nothing" apart from "not looked at yet" -- a distinction the app got
+ // wrong when the readout said "None" for both.
+ Detected bool `json:"detected"`
+}
+
+// Handler returns the routed, authenticated handler.
+func (s *Server) Handler() http.Handler {
+ mux := http.NewServeMux()
+ mux.HandleFunc("/api/health", s.health)
+ mux.HandleFunc("/api/subscriptions", s.authed(s.subscriptions))
+ mux.HandleFunc("/api/episode", s.authed(s.episode))
+ mux.HandleFunc("/audio/", s.authed(s.audio))
+ return mux
+}
+
+func (s *Server) health(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
+}
+
+// authed accepts the token in the Authorization header or the query string.
+//
+// The query string is not laziness: a media player fetching audio cannot always be made to set
+// a header, and an audio URL the phone cannot use as given would be useless.
+func (s *Server) authed(next http.HandlerFunc) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if s.opts.Token == "" || !s.tokenOK(r) {
+ w.Header().Set("WWW-Authenticate", `Bearer realm="listen"`)
+ writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
+ return
+ }
+ next(w, r)
+ }
+}
+
+func (s *Server) tokenOK(r *http.Request) bool {
+ if header := r.Header.Get("Authorization"); strings.HasPrefix(header, "Bearer ") {
+ if strings.TrimPrefix(header, "Bearer ") == s.opts.Token {
+ return true
+ }
+ }
+ return r.URL.Query().Get("token") == s.opts.Token
+}
+
+func (s *Server) subscriptions(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "post only"})
+ return
+ }
+ var body struct {
+ Feeds []string `json:"feeds"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "expected {\"feeds\":[...]}"})
+ return
+ }
+ removed := s.state.SetFeeds(body.Feeds)
+ if s.opts.OnSubscriptions != nil {
+ s.opts.OnSubscriptions(s.state.Feeds())
+ }
+ writeJSON(w, http.StatusOK, map[string]any{
+ "feeds": len(s.state.Feeds()),
+ "removed": removed,
+ })
+}
+
+func (s *Server) episode(w http.ResponseWriter, r *http.Request) {
+ q := r.URL.Query()
+ feedURL, guid := q.Get("feed"), q.Get("guid")
+ enclosure, title := q.Get("enclosure"), q.Get("title")
+
+ if feedURL == "" || (guid == "" && enclosure == "" && title == "") {
+ writeJSON(w, http.StatusBadRequest,
+ map[string]string{"error": "feed and at least one of guid, enclosure or title are required"})
+ return
+ }
+
+ found := s.state.Match(feedURL, guid, enclosure, title)
+ if found == nil || !found.Mirrored() {
+ // Ask for it rather than refusing. Everything Peter actually listens to should end up
+ // with offsets, including an episode older than the ones kept by default.
+ s.fetcher.Request(feedURL, guid, enclosure, title)
+ writeJSON(w, http.StatusOK, episodeResponse{Status: "pending"})
+ return
+ }
+ if _, err := os.Stat(filepath.Join(s.opts.AudioDir, found.Path)); err != nil {
+ // The record claims a file that has gone. Treat it as not mirrored and fetch again.
+ s.fetcher.Request(feedURL, found.GUID, found.EnclosureURL, found.Title)
+ writeJSON(w, http.StatusOK, episodeResponse{Status: "pending"})
+ return
+ }
+
+ writeJSON(w, http.StatusOK, episodeResponse{
+ Status: "ready",
+ AudioURL: "/audio/" + found.ID + "?token=" + url.QueryEscape(s.opts.Token),
+ DurationMs: found.DurationMs,
+ IntroMs: found.IntroMs,
+ OutroMs: found.OutroMs,
+ Detected: found.DetectedAtUnix != 0,
+ })
+}
+
+func (s *Server) audio(w http.ResponseWriter, r *http.Request) {
+ id := strings.TrimPrefix(r.URL.Path, "/audio/")
+ // No separators: an id is a flat hex string, and anything else is an attempt to read
+ // outside the audio directory.
+ if id == "" || strings.ContainsAny(id, "/\\") || strings.Contains(id, "..") {
+ http.NotFound(w, r)
+ return
+ }
+
+ found := s.episodeByID(id)
+ if found == nil {
+ http.NotFound(w, r)
+ return
+ }
+ path := filepath.Join(s.opts.AudioDir, found.Path)
+ info, err := os.Stat(path)
+ if err != nil || info.IsDir() {
+ http.NotFound(w, r)
+ return
+ }
+ // ServeFile handles Range, If-Modified-Since and the content type, which a podcast player
+ // needs for seeking.
+ http.ServeFile(w, r, path)
+}
+
+func (s *Server) episodeByID(id string) *store.Episode {
+ for _, show := range s.state.Shows() {
+ for _, e := range show.Episodes {
+ if e.ID == id && e.Mirrored() {
+ return e
+ }
+ }
+ }
+ return nil
+}
+
+func writeJSON(w http.ResponseWriter, status int, body any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(body)
+}
diff --git a/server/internal/api/api_test.go b/server/internal/api/api_test.go
new file mode 100644
index 0000000..21ef1de
--- /dev/null
+++ b/server/internal/api/api_test.go
@@ -0,0 +1,325 @@
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/terst/listen/server/internal/feed"
+ "github.com/terst/listen/server/internal/store"
+)
+
+const token = "s3cret"
+
+type fakeFetcher struct{ asked []string }
+
+func (f *fakeFetcher) Request(feedURL, guid, enclosureURL, title string) {
+ f.asked = append(f.asked, feedURL+" "+guid+" "+enclosureURL)
+}
+
+func newServer(t *testing.T) (*Server, *store.State, *fakeFetcher, string) {
+ t.Helper()
+ audioDir := t.TempDir()
+ st := store.New()
+ fetcher := &fakeFetcher{}
+ return New(st, fetcher, Options{Token: token, AudioDir: audioDir}), st, fetcher, audioDir
+}
+
+func get(t *testing.T, s *Server, path string) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodGet, path, nil)
+ req.Header.Set("Authorization", "Bearer "+token)
+ rec := httptest.NewRecorder()
+ s.Handler().ServeHTTP(rec, req)
+ return rec
+}
+
+func post(t *testing.T, s *Server, path, body string) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
+ req.Header.Set("Authorization", "Bearer "+token)
+ rec := httptest.NewRecorder()
+ s.Handler().ServeHTTP(rec, req)
+ return rec
+}
+
+// This serves a private library over the public internet, so nothing but health is reachable
+// without the token.
+
+func TestRejectsAnUnauthenticatedRequest(t *testing.T) {
+ s, _, _, _ := newServer(t)
+ for _, path := range []string{"/api/episode?feed=f&guid=g", "/api/subscriptions", "/audio/abc"} {
+ req := httptest.NewRequest(http.MethodGet, path, nil)
+ rec := httptest.NewRecorder()
+ s.Handler().ServeHTTP(rec, req)
+ if rec.Code != http.StatusUnauthorized {
+ t.Errorf("%s: status %d, want 401", path, rec.Code)
+ }
+ }
+}
+
+func TestRejectsAWrongToken(t *testing.T) {
+ s, _, _, _ := newServer(t)
+ req := httptest.NewRequest(http.MethodGet, "/api/episode?feed=f&guid=g", nil)
+ req.Header.Set("Authorization", "Bearer wrong")
+ rec := httptest.NewRecorder()
+ s.Handler().ServeHTTP(rec, req)
+ if rec.Code != http.StatusUnauthorized {
+ t.Errorf("status %d, want 401", rec.Code)
+ }
+}
+
+// A media player cannot always set headers on the request it makes for audio, so the token is
+// accepted in the query string too -- which is what makes the returned audio URL usable as-is.
+func TestAcceptsTheTokenAsAQueryParameter(t *testing.T) {
+ s, st, _, audioDir := newServer(t)
+ seedMirrored(t, st, audioDir, "hello")
+
+ req := httptest.NewRequest(http.MethodGet, "/audio/"+episodeID(st)+"?token="+token, nil)
+ rec := httptest.NewRecorder()
+ s.Handler().ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d, want 200", rec.Code)
+ }
+ if rec.Body.String() != "hello" {
+ t.Errorf("body %q", rec.Body.String())
+ }
+}
+
+func TestHealthNeedsNoToken(t *testing.T) {
+ s, _, _, _ := newServer(t)
+ req := httptest.NewRequest(http.MethodGet, "/api/health", nil)
+ rec := httptest.NewRecorder()
+ s.Handler().ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Errorf("status %d, want 200", rec.Code)
+ }
+}
+
+func TestSubscriptionsReplacesTheFeedList(t *testing.T) {
+ s, st, _, _ := newServer(t)
+ rec := post(t, s, "/api/subscriptions", `{"feeds":["https://a","https://b"]}`)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
+ }
+ if got := st.Feeds(); len(got) != 2 {
+ t.Errorf("feeds %v, want 2", got)
+ }
+}
+
+// The phone sends its list when it refreshes, and waiting up to a poll interval before acting
+// on it would mean a newly-subscribed show has nothing for hours.
+func TestSubscriptionsTriggersAPollImmediately(t *testing.T) {
+ audioDir := t.TempDir()
+ st := store.New()
+ notified := make(chan []string, 1)
+ s := New(st, &fakeFetcher{}, Options{
+ Token: token,
+ AudioDir: audioDir,
+ OnSubscriptions: func(feeds []string) {
+ notified <- feeds
+ },
+ })
+
+ req := httptest.NewRequest(http.MethodPost, "/api/subscriptions", strings.NewReader(`{"feeds":["https://a"]}`))
+ req.Header.Set("Authorization", "Bearer "+token)
+ s.Handler().ServeHTTP(httptest.NewRecorder(), req)
+
+ select {
+ case feeds := <-notified:
+ if len(feeds) != 1 || feeds[0] != "https://a" {
+ t.Errorf("notified with %v", feeds)
+ }
+ default:
+ t.Error("subscriptions did not trigger a poll")
+ }
+}
+
+func TestSubscriptionsRejectsRubbish(t *testing.T) {
+ s, _, _, _ := newServer(t)
+ if rec := post(t, s, "/api/subscriptions", `not json`); rec.Code != http.StatusBadRequest {
+ t.Errorf("status %d, want 400", rec.Code)
+ }
+}
+
+// An episode nobody has heard of still gets a useful answer: the server is told enough to go
+// and fetch it, and says so, rather than returning a bare 404 the phone can only give up on.
+func TestAnUnknownEpisodeIsRequestedAndReportedPending(t *testing.T) {
+ s, _, fetcher, _ := newServer(t)
+
+ rec := get(t, s, "/api/episode?feed=https://f&guid=g1&enclosure=https://cdn/1.mp3&title=One")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
+ }
+ var got episodeResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
+ t.Fatal(err)
+ }
+ if got.Status != "pending" {
+ t.Errorf("status %q, want pending", got.Status)
+ }
+ if len(fetcher.asked) != 1 {
+ t.Errorf("fetcher asked %d times, want 1", len(fetcher.asked))
+ }
+}
+
+func TestAKnownButUnmirroredEpisodeIsRequestedAndReportedPending(t *testing.T) {
+ s, st, fetcher, _ := newServer(t)
+ st.Merge("https://f", "A Show", []feed.Episode{
+ {GUID: "g1", Title: "One", EnclosureURL: "https://cdn/1.mp3", PublishedUnix: 100},
+ })
+
+ rec := get(t, s, "/api/episode?feed=https://f&guid=g1")
+ var got episodeResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
+ t.Fatal(err)
+ }
+ if got.Status != "pending" {
+ t.Errorf("status %q, want pending", got.Status)
+ }
+ if len(fetcher.asked) != 1 {
+ t.Errorf("fetcher asked %d times, want 1", len(fetcher.asked))
+ }
+}
+
+// Audio becomes usable as soon as it is mirrored, whether or not detection has run. Waiting for
+// offsets before handing over playable audio would make the server strictly worse than
+// downloading from the publisher.
+func TestAMirroredEpisodeIsReadyEvenWithoutOffsets(t *testing.T) {
+ s, st, _, audioDir := newServer(t)
+ seedMirrored(t, st, audioDir, "audio-bytes")
+
+ rec := get(t, s, "/api/episode?feed=https://f&guid=g1")
+ var got episodeResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
+ t.Fatal(err)
+ }
+ if got.Status != "ready" {
+ t.Fatalf("status %q, want ready", got.Status)
+ }
+ if got.AudioURL == "" {
+ t.Error("no audio url")
+ }
+ if got.IntroMs != nil || got.OutroMs != nil {
+ t.Errorf("reported offsets that were never detected: %v %v", got.IntroMs, got.OutroMs)
+ }
+}
+
+func TestAMirroredEpisodeReportsItsDetectedOffsets(t *testing.T) {
+ s, st, _, audioDir := newServer(t)
+ seedMirrored(t, st, audioDir, "audio-bytes")
+ e := st.Match("https://f", "g1", "", "")
+ intro, outro := int64(16652), int64(11000)
+ e.IntroMs, e.OutroMs, e.DurationMs = &intro, &outro, 1325113
+
+ rec := get(t, s, "/api/episode?feed=https://f&guid=g1")
+ var got episodeResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
+ t.Fatal(err)
+ }
+ if got.IntroMs == nil || *got.IntroMs != 16652 {
+ t.Errorf("introMs %v, want 16652", got.IntroMs)
+ }
+ if got.OutroMs == nil || *got.OutroMs != 11000 {
+ t.Errorf("outroMs %v, want 11000", got.OutroMs)
+ }
+ if got.DurationMs != 1325113 {
+ t.Errorf("durationMs %d, want the measured length", got.DurationMs)
+ }
+}
+
+// The audio URL has to carry the token, or the phone gets a link it cannot use.
+func TestTheAudioUrlIsUsableAsGiven(t *testing.T) {
+ s, st, _, audioDir := newServer(t)
+ seedMirrored(t, st, audioDir, "audio-bytes")
+
+ rec := get(t, s, "/api/episode?feed=https://f&guid=g1")
+ var got episodeResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest(http.MethodGet, got.AudioURL, nil)
+ rec2 := httptest.NewRecorder()
+ s.Handler().ServeHTTP(rec2, req)
+ if rec2.Code != http.StatusOK {
+ t.Fatalf("fetching the url it gave returned %d", rec2.Code)
+ }
+ if rec2.Body.String() != "audio-bytes" {
+ t.Errorf("body %q", rec2.Body.String())
+ }
+}
+
+// Seeking is the normal case for a podcast, so byte ranges have to work.
+func TestAudioSupportsRangeRequests(t *testing.T) {
+ s, st, _, audioDir := newServer(t)
+ seedMirrored(t, st, audioDir, "0123456789")
+
+ req := httptest.NewRequest(http.MethodGet, "/audio/"+episodeID(st)+"?token="+token, nil)
+ req.Header.Set("Range", "bytes=3-5")
+ rec := httptest.NewRecorder()
+ s.Handler().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusPartialContent {
+ t.Fatalf("status %d, want 206", rec.Code)
+ }
+ if rec.Body.String() != "345" {
+ t.Errorf("body %q, want %q", rec.Body.String(), "345")
+ }
+}
+
+func TestAudioForAnUnknownIdIs404(t *testing.T) {
+ s, _, _, _ := newServer(t)
+ if rec := get(t, s, "/audio/nope"); rec.Code != http.StatusNotFound {
+ t.Errorf("status %d, want 404", rec.Code)
+ }
+}
+
+// A record that claims a file which is no longer there must not 500.
+func TestAudioForAMissingFileIs404(t *testing.T) {
+ s, st, _, audioDir := newServer(t)
+ seedMirrored(t, st, audioDir, "gone")
+ os.Remove(filepath.Join(audioDir, st.Match("https://f", "g1", "", "").Path))
+
+ if rec := get(t, s, "/audio/"+episodeID(st)); rec.Code != http.StatusNotFound {
+ t.Errorf("status %d, want 404", rec.Code)
+ }
+}
+
+// An id containing path separators must not be able to read outside the audio directory.
+func TestAudioIdCannotEscapeTheAudioDirectory(t *testing.T) {
+ s, _, _, _ := newServer(t)
+ for _, id := range []string{"..%2f..%2fetc%2fpasswd", "../../etc/passwd"} {
+ rec := get(t, s, "/audio/"+id)
+ if rec.Code == http.StatusOK {
+ t.Errorf("%q was served", id)
+ }
+ }
+}
+
+func TestEpisodeNeedsAFeedAndSomeIdentity(t *testing.T) {
+ s, _, _, _ := newServer(t)
+ if rec := get(t, s, "/api/episode"); rec.Code != http.StatusBadRequest {
+ t.Errorf("status %d, want 400", rec.Code)
+ }
+}
+
+func seedMirrored(t *testing.T, st *store.State, audioDir, contents string) {
+ t.Helper()
+ st.Merge("https://f", "A Show", []feed.Episode{
+ {GUID: "g1", Title: "One", EnclosureURL: "https://cdn/1.mp3", PublishedUnix: 100},
+ })
+ e := st.Match("https://f", "g1", "", "")
+ e.Path = e.ID + ".mp3"
+ if err := os.WriteFile(filepath.Join(audioDir, e.Path), []byte(contents), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func episodeID(st *store.State) string {
+ return st.Match("https://f", "g1", "", "").ID
+}
diff --git a/server/internal/feed/feed.go b/server/internal/feed/feed.go
new file mode 100644
index 0000000..edc58be
--- /dev/null
+++ b/server/internal/feed/feed.go
@@ -0,0 +1,143 @@
+// Package feed parses a podcast RSS feed down to the few fields the server needs.
+package feed
+
+import (
+ "encoding/xml"
+ "fmt"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// Episode is one audio episode as its feed describes it.
+type Episode struct {
+ GUID string
+ Title string
+ // EnclosureURL is where the audio is, and the fallback identity when a GUID moves.
+ EnclosureURL string
+ EnclosureType string
+ PublishedUnix int64
+ // DeclaredDurationMs is the feed's claim, which is routinely wrong -- a show using dynamic
+ // ad insertion declares the ad-free length. Useful for ordering, not for arithmetic.
+ DeclaredDurationMs int64
+}
+
+// Channel is a show and its audio episodes, newest first.
+type Channel struct {
+ Title string
+ Episodes []Episode
+}
+
+type rss struct {
+ Channel struct {
+ Title string `xml:"title"`
+ Items []struct {
+ Title string `xml:"title"`
+ GUID string `xml:"guid"`
+ PubDate string `xml:"pubDate"`
+ Duration string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd duration"`
+ Enclosure struct {
+ URL string `xml:"url,attr"`
+ Type string `xml:"type,attr"`
+ } `xml:"enclosure"`
+ } `xml:"item"`
+ } `xml:"channel"`
+}
+
+// Parse reads a feed and returns its audio episodes, newest first.
+//
+// Audio only: video was cut from the app's scope, and a YouTube or Facebook episode has no
+// enclosure the server could fetch regardless -- its audio only exists once yt-dlp has resolved
+// it, which stays the phone's job.
+func Parse(raw []byte) (*Channel, error) {
+ var doc rss
+ if err := xml.Unmarshal(raw, &doc); err != nil {
+ return nil, fmt.Errorf("parse feed: %w", err)
+ }
+ if doc.Channel.Title == "" && len(doc.Channel.Items) == 0 {
+ return nil, fmt.Errorf("parse feed: no channel found")
+ }
+
+ out := &Channel{Title: strings.TrimSpace(doc.Channel.Title)}
+ for _, item := range doc.Channel.Items {
+ if !isAudio(item.Enclosure.Type, item.Enclosure.URL) {
+ continue
+ }
+ out.Episodes = append(out.Episodes, Episode{
+ GUID: strings.TrimSpace(item.GUID),
+ Title: strings.TrimSpace(item.Title),
+ EnclosureURL: strings.TrimSpace(item.Enclosure.URL),
+ EnclosureType: strings.TrimSpace(item.Enclosure.Type),
+ PublishedUnix: parseDate(item.PubDate),
+ DeclaredDurationMs: parseDuration(item.Duration),
+ })
+ }
+
+ // Newest first. Feeds are not reliably ordered, and the retention rule depends on this.
+ sort.SliceStable(out.Episodes, func(i, j int) bool {
+ return out.Episodes[i].PublishedUnix > out.Episodes[j].PublishedUnix
+ })
+ return out, nil
+}
+
+func isAudio(mediaType, url string) bool {
+ if url == "" {
+ return false
+ }
+ if strings.HasPrefix(strings.ToLower(mediaType), "audio/") {
+ return true
+ }
+ // Some feeds omit or mistype the enclosure type; fall back to the extension rather than
+ // dropping a real episode over a publisher's metadata slip.
+ if mediaType == "" {
+ lower := strings.ToLower(url)
+ for _, ext := range []string{".mp3", ".m4a", ".aac", ".ogg", ".opus", ".flac", ".wav"} {
+ if strings.Contains(lower, ext) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// dateLayouts covers what podcast feeds actually emit, which is RFC 1123 with and without a
+// named zone plus a few near-misses.
+var dateLayouts = []string{
+ time.RFC1123Z, time.RFC1123, time.RFC822Z, time.RFC822, time.RFC3339,
+ "Mon, 2 Jan 2006 15:04:05 -0700",
+ "Mon, 2 Jan 2006 15:04:05 MST",
+ "Mon, 02 Jan 2006 15:04 -0700",
+ "2 Jan 2006 15:04:05 -0700",
+}
+
+func parseDate(raw string) int64 {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return 0
+ }
+ for _, layout := range dateLayouts {
+ if t, err := time.Parse(layout, raw); err == nil {
+ return t.Unix()
+ }
+ }
+ return 0
+}
+
+// parseDuration reads itunes:duration, which appears as bare seconds, mm:ss, or hh:mm:ss.
+func parseDuration(raw string) int64 {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return 0
+ }
+ parts := strings.Split(raw, ":")
+ var total int64
+ for _, p := range parts {
+ n, err := strconv.ParseInt(strings.TrimSpace(p), 10, 64)
+ if err != nil {
+ return 0
+ }
+ total = total*60 + n
+ }
+ return total * 1000
+}
diff --git a/server/internal/feed/feed_test.go b/server/internal/feed/feed_test.go
new file mode 100644
index 0000000..e62558f
--- /dev/null
+++ b/server/internal/feed/feed_test.go
@@ -0,0 +1,145 @@
+package feed
+
+import "testing"
+
+// Real podcast feeds, trimmed. The shapes here are the ones that actually caused trouble in
+// the Android parser and are worth not relearning: a GUID that is not a URL, an enclosure whose
+// type is video, CDATA titles, and an itunes:duration in three different formats.
+
+const sample = `<?xml version="1.0" encoding="UTF-8"?>
+<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
+<channel>
+ <title>A Show</title>
+ <item>
+ <title><![CDATA[Newest Episode]]></title>
+ <guid isPermaLink="false">tag:example,2026:1</guid>
+ <pubDate>Fri, 03 Oct 2026 10:00:00 -0000</pubDate>
+ <itunes:duration>00:22:05</itunes:duration>
+ <enclosure url="https://cdn.example/1.mp3" type="audio/mpeg" length="21000000"/>
+ </item>
+ <item>
+ <title>Second Episode</title>
+ <guid>https://example.com/2</guid>
+ <pubDate>Fri, 26 Sep 2026 10:00:00 -0000</pubDate>
+ <itunes:duration>1325</itunes:duration>
+ <enclosure url="https://cdn.example/2.mp3" type="audio/mpeg"/>
+ </item>
+ <item>
+ <title>A Video One</title>
+ <guid>https://example.com/3</guid>
+ <pubDate>Fri, 19 Sep 2026 10:00:00 -0000</pubDate>
+ <enclosure url="https://cdn.example/3.mp4" type="video/mp4"/>
+ </item>
+ <item>
+ <title>No Enclosure At All</title>
+ <guid>https://example.com/4</guid>
+ </item>
+ <item>
+ <title>Minutes And Seconds</title>
+ <guid>https://example.com/5</guid>
+ <pubDate>Fri, 12 Sep 2026 10:00:00 -0000</pubDate>
+ <itunes:duration>31:02</itunes:duration>
+ <enclosure url="https://cdn.example/5.mp3" type="audio/mpeg"/>
+ </item>
+</channel>
+</rss>`
+
+func TestParsesTheChannelTitle(t *testing.T) {
+ ch, err := Parse([]byte(sample))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if ch.Title != "A Show" {
+ t.Errorf("title %q, want %q", ch.Title, "A Show")
+ }
+}
+
+// Audio only. Video was cut from the app's scope, and an episode whose audio comes from yt-dlp
+// has no enclosure the server could fetch anyway.
+func TestKeepsOnlyAudioEnclosures(t *testing.T) {
+ ch, err := Parse([]byte(sample))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(ch.Episodes) != 3 {
+ var got []string
+ for _, e := range ch.Episodes {
+ got = append(got, e.Title)
+ }
+ t.Fatalf("kept %d episodes (%v), want 3 audio ones", len(ch.Episodes), got)
+ }
+ for _, e := range ch.Episodes {
+ if e.EnclosureURL == "https://cdn.example/3.mp4" {
+ t.Error("kept a video enclosure")
+ }
+ }
+}
+
+func TestReadsTheFieldsNeededToIdentifyAnEpisode(t *testing.T) {
+ ch, err := Parse([]byte(sample))
+ if err != nil {
+ t.Fatal(err)
+ }
+ first := ch.Episodes[0]
+ if first.Title != "Newest Episode" {
+ t.Errorf("title %q, want CDATA content unwrapped", first.Title)
+ }
+ if first.GUID != "tag:example,2026:1" {
+ t.Errorf("guid %q, want the non-URL guid verbatim", first.GUID)
+ }
+ if first.EnclosureURL != "https://cdn.example/1.mp3" {
+ t.Errorf("enclosure %q", first.EnclosureURL)
+ }
+ if first.PublishedUnix == 0 {
+ t.Error("published date not parsed")
+ }
+}
+
+// A declared duration is the feed's claim and routinely wrong, but it is still what decides
+// which episodes look newest before anything has been downloaded.
+func TestParsesEveryDurationFormatFeedsActuallyUse(t *testing.T) {
+ ch, err := Parse([]byte(sample))
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := map[string]int64{
+ "Newest Episode": 1325000, // 00:22:05
+ "Second Episode": 1325000, // bare seconds
+ "Minutes And Seconds": 1862000, // 31:02
+ }
+ for _, e := range ch.Episodes {
+ if w, ok := want[e.Title]; ok && e.DeclaredDurationMs != w {
+ t.Errorf("%s: duration %dms, want %dms", e.Title, e.DeclaredDurationMs, w)
+ }
+ }
+}
+
+// Newest first, because that is the order the retention rule needs and feeds are not reliably
+// sorted.
+func TestOrdersEpisodesNewestFirst(t *testing.T) {
+ ch, err := Parse([]byte(sample))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for i := 1; i < len(ch.Episodes); i++ {
+ if ch.Episodes[i-1].PublishedUnix < ch.Episodes[i].PublishedUnix {
+ t.Errorf("episode %d is newer than %d", i, i-1)
+ }
+ }
+}
+
+func TestRejectsSomethingThatIsNotAFeed(t *testing.T) {
+ if _, err := Parse([]byte("not xml at all")); err == nil {
+ t.Error("expected an error")
+ }
+}
+
+func TestAFeedWithNoItemsIsNotAnError(t *testing.T) {
+ ch, err := Parse([]byte(`<rss><channel><title>Empty</title></channel></rss>`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(ch.Episodes) != 0 {
+ t.Errorf("got %d episodes", len(ch.Episodes))
+ }
+}
diff --git a/server/internal/mirror/mirror.go b/server/internal/mirror/mirror.go
new file mode 100644
index 0000000..9e91041
--- /dev/null
+++ b/server/internal/mirror/mirror.go
@@ -0,0 +1,156 @@
+// Package mirror keeps a local copy of the audio the server detects against and the phone
+// plays.
+package mirror
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "path"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/terst/listen/server/internal/store"
+)
+
+// Probe measures a downloaded file's real length.
+type Probe interface {
+ DurationMs(ctx context.Context, path string) (int64, error)
+}
+
+// Mirror downloads episodes and reclaims them.
+//
+// The reason the server holds the audio at all, rather than only timestamps: an episode fetched
+// twice is not the same file when a show uses dynamic ad insertion, so an offset measured on one
+// copy points at the wrong moment in another. The phone playing the same bytes the detector
+// examined is what makes the offsets correct.
+type Mirror struct {
+ AudioDir string
+ Probe Probe
+ // Client defaults to a client with a generous timeout: episodes are tens of megabytes.
+ Client *http.Client
+ // UserAgent identifies this server to publishers.
+ UserAgent string
+}
+
+// DefaultTimeout bounds one episode download.
+const DefaultTimeout = 15 * time.Minute
+
+func (m *Mirror) client() *http.Client {
+ if m.Client != nil {
+ return m.Client
+ }
+ return &http.Client{Timeout: DefaultTimeout}
+}
+
+func (m *Mirror) userAgent() string {
+ if m.UserAgent != "" {
+ return m.UserAgent
+ }
+ return "listen-server/1.0 (+https://terst.org)"
+}
+
+// Fetch downloads an episode's audio and records where it went and how long it really is.
+//
+// Writes to a temporary file and renames, so an interrupted download cannot leave a truncated
+// file that looks mirrored -- which would have the phone play a cut-off episode and the
+// detector correlate against noise.
+func (m *Mirror) Fetch(ctx context.Context, e *store.Episode) error {
+ if e.EnclosureURL == "" {
+ return fmt.Errorf("episode %s: nothing to download", e.ID)
+ }
+ if err := os.MkdirAll(m.AudioDir, 0o755); err != nil {
+ return fmt.Errorf("episode %s: %w", e.ID, err)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, e.EnclosureURL, nil)
+ if err != nil {
+ return fmt.Errorf("episode %s: %w", e.ID, err)
+ }
+ req.Header.Set("User-Agent", m.userAgent())
+
+ resp, err := m.client().Do(req)
+ if err != nil {
+ return fmt.Errorf("episode %s: %w", e.ID, err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("episode %s: %s returned %s", e.ID, e.EnclosureURL, resp.Status)
+ }
+
+ name := e.ID + extensionFor(e.EnclosureURL, resp.Header.Get("Content-Type"))
+ tmp, err := os.CreateTemp(m.AudioDir, ".download-*")
+ if err != nil {
+ return fmt.Errorf("episode %s: %w", e.ID, err)
+ }
+ tmpName := tmp.Name()
+ defer os.Remove(tmpName)
+
+ if _, err := io.Copy(tmp, resp.Body); err != nil {
+ tmp.Close()
+ return fmt.Errorf("episode %s: %w", e.ID, err)
+ }
+ if err := tmp.Close(); err != nil {
+ return fmt.Errorf("episode %s: %w", e.ID, err)
+ }
+
+ final := filepath.Join(m.AudioDir, name)
+ if err := os.Rename(tmpName, final); err != nil {
+ return fmt.Errorf("episode %s: %w", e.ID, err)
+ }
+
+ // The real length, which is routinely not what the feed declared.
+ if ms, err := m.Probe.DurationMs(ctx, final); err == nil {
+ e.DurationMs = ms
+ }
+ e.Path = name
+ return nil
+}
+
+// Prune deletes an episode's audio, leaving everything detection decided about it in place.
+//
+// The offsets describe the episode, not the file, and re-deriving them would mean downloading
+// it again -- so they outlive the audio.
+func (m *Mirror) Prune(e *store.Episode) error {
+ if e.Path == "" {
+ return nil
+ }
+ err := os.Remove(filepath.Join(m.AudioDir, e.Path))
+ e.Path = ""
+ if err != nil && !os.IsNotExist(err) {
+ return fmt.Errorf("episode %s: %w", e.ID, err)
+ }
+ return nil
+}
+
+// extensionFor picks a file extension, preferring the URL's since a player chooses its decoder
+// from it.
+func extensionFor(rawURL, contentType string) string {
+ if u, err := url.Parse(rawURL); err == nil {
+ if ext := path.Ext(u.Path); isAudioExt(ext) {
+ return ext
+ }
+ }
+ switch {
+ case strings.Contains(contentType, "mp4"), strings.Contains(contentType, "m4a"):
+ return ".m4a"
+ case strings.Contains(contentType, "aac"):
+ return ".aac"
+ case strings.Contains(contentType, "ogg"), strings.Contains(contentType, "opus"):
+ return ".ogg"
+ default:
+ return ".mp3"
+ }
+}
+
+func isAudioExt(ext string) bool {
+ switch strings.ToLower(ext) {
+ case ".mp3", ".m4a", ".m4b", ".aac", ".ogg", ".opus", ".flac", ".wav":
+ return true
+ }
+ return false
+}
diff --git a/server/internal/mirror/mirror_test.go b/server/internal/mirror/mirror_test.go
new file mode 100644
index 0000000..b510451
--- /dev/null
+++ b/server/internal/mirror/mirror_test.go
@@ -0,0 +1,162 @@
+package mirror
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/terst/listen/server/internal/store"
+)
+
+type stubProbe struct{ ms int64 }
+
+func (s stubProbe) DurationMs(context.Context, string) (int64, error) { return s.ms, nil }
+
+func episode(id, url string) *store.Episode {
+ return &store.Episode{ID: id, GUID: "g-" + id, EnclosureURL: url, Title: "An Episode"}
+}
+
+func TestFetchWritesTheAudioAndRecordsWhereItWent(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "audio/mpeg")
+ _, _ = w.Write([]byte("fake-mp3-bytes"))
+ }))
+ defer srv.Close()
+
+ dir := t.TempDir()
+ m := &Mirror{AudioDir: dir, Probe: stubProbe{ms: 1325113}}
+ e := episode("abc", srv.URL+"/1.mp3")
+
+ if err := m.Fetch(context.Background(), e); err != nil {
+ t.Fatal(err)
+ }
+ if e.Path == "" {
+ t.Fatal("Path not recorded")
+ }
+ raw, err := os.ReadFile(filepath.Join(dir, e.Path))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(raw) != "fake-mp3-bytes" {
+ t.Errorf("contents %q", raw)
+ }
+ // The measured length, not the feed's claim: everything downstream reads this.
+ if e.DurationMs != 1325113 {
+ t.Errorf("DurationMs %d, want 1325113", e.DurationMs)
+ }
+}
+
+// The file is named by the episode id, which is stable and is what the audio URL already uses.
+func TestFetchNamesTheFileByEpisodeId(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = w.Write([]byte("x"))
+ }))
+ defer srv.Close()
+
+ m := &Mirror{AudioDir: t.TempDir(), Probe: stubProbe{ms: 1}}
+ e := episode("deadbeef", srv.URL+"/whatever.m4a")
+ if err := m.Fetch(context.Background(), e); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.HasPrefix(e.Path, "deadbeef") {
+ t.Errorf("Path %q does not start with the episode id", e.Path)
+ }
+ if filepath.Ext(e.Path) != ".m4a" {
+ t.Errorf("Path %q lost the extension, which the player needs to pick a decoder", e.Path)
+ }
+}
+
+// A failed or interrupted download must not leave a half file that looks mirrored. That is the
+// state that would have the phone play a truncated episode and the detector read noise.
+func TestAFailedFetchLeavesNothingBehind(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ http.Error(w, "gone", http.StatusNotFound)
+ }))
+ defer srv.Close()
+
+ dir := t.TempDir()
+ m := &Mirror{AudioDir: dir, Probe: stubProbe{ms: 1}}
+ e := episode("abc", srv.URL+"/1.mp3")
+
+ if err := m.Fetch(context.Background(), e); err == nil {
+ t.Fatal("expected an error")
+ }
+ if e.Path != "" {
+ t.Errorf("recorded a path for a download that failed: %q", e.Path)
+ }
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(entries) != 0 {
+ t.Errorf("left %d files behind", len(entries))
+ }
+}
+
+func TestFetchRefusesAnEpisodeWithNoEnclosure(t *testing.T) {
+ m := &Mirror{AudioDir: t.TempDir(), Probe: stubProbe{ms: 1}}
+ if err := m.Fetch(context.Background(), episode("abc", "")); err == nil {
+ t.Error("expected an error for an episode with nothing to download")
+ }
+}
+
+func TestPruneRemovesTheFileAndForgetsThePath(t *testing.T) {
+ dir := t.TempDir()
+ m := &Mirror{AudioDir: dir, Probe: stubProbe{ms: 1}}
+ e := episode("abc", "https://cdn/1.mp3")
+ e.Path = "abc.mp3"
+ if err := os.WriteFile(filepath.Join(dir, e.Path), []byte("bytes"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := m.Prune(e); err != nil {
+ t.Fatal(err)
+ }
+ if e.Path != "" {
+ t.Errorf("Path still set: %q", e.Path)
+ }
+ if _, err := os.Stat(filepath.Join(dir, "abc.mp3")); !os.IsNotExist(err) {
+ t.Error("file still on disk")
+ }
+}
+
+// Detected offsets outlive the audio: they describe the episode, and re-deriving them would
+// mean downloading it again.
+func TestPruneKeepsWhatDetectionDecided(t *testing.T) {
+ dir := t.TempDir()
+ m := &Mirror{AudioDir: dir, Probe: stubProbe{ms: 1}}
+ e := episode("abc", "https://cdn/1.mp3")
+ e.Path = "abc.mp3"
+ intro := int64(16652)
+ e.IntroMs = &intro
+ e.DurationMs = 1325113
+ if err := os.WriteFile(filepath.Join(dir, e.Path), []byte("bytes"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := m.Prune(e); err != nil {
+ t.Fatal(err)
+ }
+ if e.IntroMs == nil || *e.IntroMs != 16652 {
+ t.Errorf("lost the detected intro: %v", e.IntroMs)
+ }
+ if e.DurationMs != 1325113 {
+ t.Errorf("lost the measured duration: %d", e.DurationMs)
+ }
+}
+
+func TestPruningSomethingAlreadyGoneIsFine(t *testing.T) {
+ m := &Mirror{AudioDir: t.TempDir(), Probe: stubProbe{ms: 1}}
+ e := episode("abc", "https://cdn/1.mp3")
+ e.Path = "not-there.mp3"
+ if err := m.Prune(e); err != nil {
+ t.Errorf("pruning a missing file errored: %v", err)
+ }
+ if e.Path != "" {
+ t.Error("Path not cleared")
+ }
+}
diff --git a/server/internal/store/id.go b/server/internal/store/id.go
new file mode 100644
index 0000000..52bab46
--- /dev/null
+++ b/server/internal/store/id.go
@@ -0,0 +1,20 @@
+package store
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+)
+
+// newID is a stable identifier for an episode, derived from what identifies it rather than
+// from a counter or a random value.
+//
+// Stable matters because it names the audio file on disk and appears in the URL the phone
+// fetches: a regenerated id would orphan the file and break a link the phone already holds.
+func newID(feedURL, guid, enclosureURL string) string {
+ key := guid
+ if key == "" {
+ key = enclosureURL
+ }
+ sum := sha256.Sum256([]byte(feedURL + "\x00" + key))
+ return hex.EncodeToString(sum[:12])
+}
diff --git a/server/internal/store/store.go b/server/internal/store/store.go
new file mode 100644
index 0000000..cfe6f40
--- /dev/null
+++ b/server/internal/store/store.go
@@ -0,0 +1,295 @@
+// Package store holds what the server knows: which feeds to poll, which episodes exist, where
+// their audio is, and what detection decided about them.
+package store
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "sync"
+
+ "github.com/terst/listen/server/internal/feed"
+)
+
+// Episode is one episode's full server-side record.
+type Episode struct {
+ ID string `json:"id"`
+ GUID string `json:"guid"`
+ Title string `json:"title"`
+ EnclosureURL string `json:"enclosureUrl"`
+ PublishedUnix int64 `json:"publishedUnix"`
+ // DeclaredDurationMs is the feed's claim; DurationMs is what the file turned out to be.
+ DeclaredDurationMs int64 `json:"declaredDurationMs"`
+ DurationMs int64 `json:"durationMs,omitempty"`
+
+ // Path is where the mirrored audio sits, relative to the audio directory. Empty until
+ // the episode has been downloaded.
+ Path string `json:"path,omitempty"`
+
+ // IntroMs and OutroMs are what the phone needs, in this episode's own timeline. Nil until
+ // detection has confirmed something that passed its self-check.
+ IntroMs *int64 `json:"introMs,omitempty"`
+ OutroMs *int64 `json:"outroMs,omitempty"`
+
+ // DetectedAtUnix records when detection last ran over the show this belongs to, so a
+ // result can be told apart from never having looked.
+ DetectedAtUnix int64 `json:"detectedAtUnix,omitempty"`
+}
+
+// Mirrored reports whether this episode's audio is on disk.
+func (e *Episode) Mirrored() bool { return e.Path != "" }
+
+// Show is a subscribed feed and everything known about its episodes.
+type Show struct {
+ FeedURL string `json:"feedUrl"`
+ Title string `json:"title"`
+ Episodes []*Episode `json:"episodes"`
+
+ // LastPolledUnix and LastError record the most recent refresh, so a feed that is failing
+ // says so instead of silently having nothing.
+ LastPolledUnix int64 `json:"lastPolledUnix,omitempty"`
+ LastError string `json:"lastError,omitempty"`
+}
+
+// Partition splits a show's episodes into the newest keep and everything else, newest first.
+//
+// By publish date rather than feed order, because feeds are not reliably sorted and the whole
+// point is to mirror what is current.
+func (s *Show) Partition(keep int) (kept, pruned []*Episode) {
+ ordered := make([]*Episode, len(s.Episodes))
+ copy(ordered, s.Episodes)
+ sort.SliceStable(ordered, func(i, j int) bool {
+ return ordered[i].PublishedUnix > ordered[j].PublishedUnix
+ })
+ if keep >= len(ordered) {
+ return ordered, nil
+ }
+ return ordered[:keep], ordered[keep:]
+}
+
+// State is the server's whole persistent world.
+type State struct {
+ mu sync.RWMutex
+ feeds []string
+ shows map[string]*Show
+}
+
+type persisted struct {
+ Feeds []string `json:"feeds"`
+ Shows map[string]*Show `json:"shows"`
+}
+
+// New returns an empty state.
+func New() *State {
+ return &State{shows: map[string]*Show{}}
+}
+
+// Load reads state from disk. A missing file is a first run, not an error.
+func Load(path string) (*State, error) {
+ raw, err := os.ReadFile(path)
+ if os.IsNotExist(err) {
+ return New(), nil
+ }
+ if err != nil {
+ return nil, fmt.Errorf("load state: %w", err)
+ }
+ var p persisted
+ if err := json.Unmarshal(raw, &p); err != nil {
+ return nil, fmt.Errorf("load state: %w", err)
+ }
+ s := New()
+ s.feeds = p.Feeds
+ if p.Shows != nil {
+ s.shows = p.Shows
+ }
+ return s, nil
+}
+
+// Save writes state to disk atomically.
+//
+// Via a temporary file and a rename, because this holds the only record of where mirrored audio
+// is and what detection decided: a truncated write would orphan every file on disk and throw
+// away hours of detection.
+func (s *State) Save(path string) error {
+ s.mu.RLock()
+ raw, err := json.MarshalIndent(persisted{Feeds: s.feeds, Shows: s.shows}, "", " ")
+ s.mu.RUnlock()
+ if err != nil {
+ return fmt.Errorf("save state: %w", err)
+ }
+
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return fmt.Errorf("save state: %w", err)
+ }
+ tmp, err := os.CreateTemp(filepath.Dir(path), ".state-*.tmp")
+ if err != nil {
+ return fmt.Errorf("save state: %w", err)
+ }
+ tmpName := tmp.Name()
+ defer os.Remove(tmpName)
+
+ if _, err := tmp.Write(raw); err != nil {
+ tmp.Close()
+ return fmt.Errorf("save state: %w", err)
+ }
+ if err := tmp.Sync(); err != nil {
+ tmp.Close()
+ return fmt.Errorf("save state: %w", err)
+ }
+ if err := tmp.Close(); err != nil {
+ return fmt.Errorf("save state: %w", err)
+ }
+ if err := os.Rename(tmpName, path); err != nil {
+ return fmt.Errorf("save state: %w", err)
+ }
+ return nil
+}
+
+// SetFeeds replaces the subscription list and reports the feeds that are no longer wanted.
+//
+// Wholesale replacement because the phone owns this list; reconciling two editable copies of
+// something changed this rarely would cost more in sync bugs than it saves.
+func (s *State) SetFeeds(feeds []string) (removed []string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ wanted := make(map[string]bool, len(feeds))
+ clean := make([]string, 0, len(feeds))
+ for _, f := range feeds {
+ f = strings.TrimSpace(f)
+ if f == "" || wanted[f] {
+ continue
+ }
+ wanted[f] = true
+ clean = append(clean, f)
+ }
+ for url := range s.shows {
+ if !wanted[url] {
+ removed = append(removed, url)
+ }
+ }
+ sort.Strings(removed)
+ s.feeds = clean
+ return removed
+}
+
+// Feeds is the current subscription list.
+func (s *State) Feeds() []string {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return append([]string(nil), s.feeds...)
+}
+
+// Forget drops a show and returns it, so its files can be reclaimed.
+func (s *State) Forget(feedURL string) *Show {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ show := s.shows[feedURL]
+ delete(s.shows, feedURL)
+ return show
+}
+
+// Show returns a feed's show, or nil.
+func (s *State) Show(feedURL string) *Show {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return s.shows[feedURL]
+}
+
+// Shows returns every tracked show.
+func (s *State) Shows() []*Show {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ out := make([]*Show, 0, len(s.shows))
+ for _, sh := range s.shows {
+ out = append(out, sh)
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].FeedURL < out[j].FeedURL })
+ return out
+}
+
+// Merge folds a parsed feed into stored state, adding episodes and refreshing feed-owned
+// fields while leaving everything the server learned alone.
+//
+// The distinction matters and has bitten the Android side twice: feed metadata is rewritten on
+// every refresh, so anything measured or detected has to live in fields the merge does not
+// touch, or it silently reverts.
+func (s *State) Merge(feedURL, title string, episodes []feed.Episode) *Show {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ show := s.shows[feedURL]
+ if show == nil {
+ show = &Show{FeedURL: feedURL}
+ s.shows[feedURL] = show
+ }
+ if title != "" {
+ show.Title = title
+ }
+
+ for _, parsed := range episodes {
+ existing := matchIn(show, parsed.GUID, parsed.EnclosureURL, parsed.Title)
+ if existing == nil {
+ show.Episodes = append(show.Episodes, &Episode{
+ ID: newID(feedURL, parsed.GUID, parsed.EnclosureURL),
+ GUID: parsed.GUID,
+ Title: parsed.Title,
+ EnclosureURL: parsed.EnclosureURL,
+ PublishedUnix: parsed.PublishedUnix,
+ DeclaredDurationMs: parsed.DeclaredDurationMs,
+ })
+ continue
+ }
+ // Feed-owned fields only. Path, DurationMs, IntroMs, OutroMs are the server's.
+ existing.GUID = parsed.GUID
+ existing.Title = parsed.Title
+ existing.EnclosureURL = parsed.EnclosureURL
+ if parsed.PublishedUnix != 0 {
+ existing.PublishedUnix = parsed.PublishedUnix
+ }
+ existing.DeclaredDurationMs = parsed.DeclaredDurationMs
+ }
+ return show
+}
+
+// Match finds an episode within a show, on GUID, then enclosure URL, then title.
+//
+// That cascade exists because publishers rewrite feeds: an episode that loses its identity
+// loses its playback position on the phone and its detected offsets here.
+func (s *State) Match(feedURL, guid, enclosureURL, title string) *Episode {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ show := s.shows[feedURL]
+ if show == nil {
+ return nil
+ }
+ return matchIn(show, guid, enclosureURL, title)
+}
+
+func matchIn(show *Show, guid, enclosureURL, title string) *Episode {
+ if guid != "" {
+ for _, e := range show.Episodes {
+ if e.GUID == guid {
+ return e
+ }
+ }
+ }
+ if enclosureURL != "" {
+ for _, e := range show.Episodes {
+ if e.EnclosureURL == enclosureURL {
+ return e
+ }
+ }
+ }
+ if title != "" {
+ for _, e := range show.Episodes {
+ if e.Title == title {
+ return e
+ }
+ }
+ }
+ return nil
+}
diff --git a/server/internal/store/store_test.go b/server/internal/store/store_test.go
new file mode 100644
index 0000000..cd5cf6f
--- /dev/null
+++ b/server/internal/store/store_test.go
@@ -0,0 +1,239 @@
+package store
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/terst/listen/server/internal/feed"
+)
+
+func ep(guid, title, enclosure string, published int64) feed.Episode {
+ return feed.Episode{GUID: guid, Title: title, EnclosureURL: enclosure, PublishedUnix: published}
+}
+
+// Merging a refreshed feed into stored state must not lose what the server has learned. The
+// Android side has already had this bug twice in different forms -- a corrected duration
+// overwritten on every refresh, and episode rows cascading away carrying the only record of
+// where their files were.
+
+func TestMergeKeepsWhatTheServerLearnedAboutAnEpisode(t *testing.T) {
+ s := New()
+ s.Merge("https://f", "A Show", []feed.Episode{ep("g1", "One", "https://cdn/1.mp3", 100)})
+
+ got := s.Match("https://f", "g1", "", "")
+ if got == nil {
+ t.Fatal("episode not stored")
+ }
+ got.Path = "audio/abc.mp3"
+ got.DurationMs = 1325113
+ intro := int64(16652)
+ got.IntroMs = &intro
+
+ // The same feed again, as a refresh would deliver it.
+ s.Merge("https://f", "A Show", []feed.Episode{ep("g1", "One", "https://cdn/1.mp3", 100)})
+
+ after := s.Match("https://f", "g1", "", "")
+ if after == nil {
+ t.Fatal("episode lost on refresh")
+ }
+ if after.Path != "audio/abc.mp3" {
+ t.Errorf("mirrored path lost: %q", after.Path)
+ }
+ if after.DurationMs != 1325113 {
+ t.Errorf("measured duration lost: %d", after.DurationMs)
+ }
+ if after.IntroMs == nil || *after.IntroMs != 16652 {
+ t.Errorf("detected intro lost: %v", after.IntroMs)
+ }
+}
+
+func TestMergeAddsNewEpisodesAndKeepsOldOnes(t *testing.T) {
+ s := New()
+ s.Merge("https://f", "A Show", []feed.Episode{ep("g1", "One", "https://cdn/1.mp3", 100)})
+ s.Merge("https://f", "A Show", []feed.Episode{
+ ep("g2", "Two", "https://cdn/2.mp3", 200),
+ ep("g1", "One", "https://cdn/1.mp3", 100),
+ })
+
+ if n := len(s.Show("https://f").Episodes); n != 2 {
+ t.Errorf("%d episodes, want 2", n)
+ }
+}
+
+// GUIDs move: publishers rewrite feeds. Matching on the GUID alone loses an episode's
+// identity, and with it its playback position on the phone and its detected offsets here.
+func TestMatchFallsBackToTheEnclosureUrlWhenTheGuidChanged(t *testing.T) {
+ s := New()
+ s.Merge("https://f", "A Show", []feed.Episode{ep("old-guid", "One", "https://cdn/1.mp3", 100)})
+
+ got := s.Match("https://f", "new-guid", "https://cdn/1.mp3", "")
+ if got == nil {
+ t.Fatal("not matched by enclosure url")
+ }
+ if got.GUID != "old-guid" {
+ t.Errorf("matched the wrong episode: %q", got.GUID)
+ }
+}
+
+func TestMatchFallsBackToTheTitleWhenBothGuidAndUrlChanged(t *testing.T) {
+ s := New()
+ s.Merge("https://f", "A Show", []feed.Episode{ep("old-guid", "Grass Apes", "https://cdn/old.mp3", 100)})
+
+ got := s.Match("https://f", "new-guid", "https://cdn/new.mp3", "Grass Apes")
+ if got == nil {
+ t.Fatal("not matched by title")
+ }
+ if got.GUID != "old-guid" {
+ t.Errorf("matched the wrong episode: %q", got.GUID)
+ }
+}
+
+// The cascade has to be in that order: a GUID match must win over an enclosure match, or a
+// re-uploaded file would steal another episode's identity.
+func TestMatchPrefersTheGuidOverTheEnclosureUrl(t *testing.T) {
+ s := New()
+ s.Merge("https://f", "A Show", []feed.Episode{
+ ep("g1", "One", "https://cdn/shared.mp3", 100),
+ ep("g2", "Two", "https://cdn/shared.mp3", 200),
+ })
+
+ got := s.Match("https://f", "g2", "https://cdn/shared.mp3", "")
+ if got == nil || got.GUID != "g2" {
+ t.Fatalf("matched %v, want g2", got)
+ }
+}
+
+func TestMatchReturnsNothingForAnUnknownEpisode(t *testing.T) {
+ s := New()
+ s.Merge("https://f", "A Show", []feed.Episode{ep("g1", "One", "https://cdn/1.mp3", 100)})
+
+ if got := s.Match("https://f", "nope", "https://cdn/nope.mp3", "Nope"); got != nil {
+ t.Errorf("matched %q for an unknown episode", got.GUID)
+ }
+}
+
+func TestMatchIsScopedToTheShow(t *testing.T) {
+ s := New()
+ s.Merge("https://a", "Show A", []feed.Episode{ep("g1", "One", "https://cdn/1.mp3", 100)})
+ s.Merge("https://b", "Show B", []feed.Episode{})
+
+ if got := s.Match("https://b", "g1", "", ""); got != nil {
+ t.Error("matched an episode from a different show")
+ }
+}
+
+// Retention: newest N by publish date. Anything the server is mid-way through using has to
+// survive, which is the lesson from the phone's own retention reclaiming the file detection
+// was holding.
+func TestKeepNewestSelectsByPublishDateNotFeedOrder(t *testing.T) {
+ s := New()
+ s.Merge("https://f", "A Show", []feed.Episode{
+ ep("g1", "Oldest", "https://cdn/1.mp3", 100),
+ ep("g3", "Newest", "https://cdn/3.mp3", 300),
+ ep("g2", "Middle", "https://cdn/2.mp3", 200),
+ })
+
+ keep, prune := s.Show("https://f").Partition(2)
+ if len(keep) != 2 || len(prune) != 1 {
+ t.Fatalf("keep %d prune %d, want 2 and 1", len(keep), len(prune))
+ }
+ if keep[0].GUID != "g3" || keep[1].GUID != "g2" {
+ t.Errorf("kept %s and %s, want g3 and g2", keep[0].GUID, keep[1].GUID)
+ }
+ if prune[0].GUID != "g1" {
+ t.Errorf("pruned %s, want g1", prune[0].GUID)
+ }
+}
+
+func TestKeepNewestKeepsEverythingWhenThereAreFewerThanTheLimit(t *testing.T) {
+ s := New()
+ s.Merge("https://f", "A Show", []feed.Episode{ep("g1", "One", "https://cdn/1.mp3", 100)})
+
+ keep, prune := s.Show("https://f").Partition(8)
+ if len(keep) != 1 || len(prune) != 0 {
+ t.Errorf("keep %d prune %d, want 1 and 0", len(keep), len(prune))
+ }
+}
+
+// Subscriptions are replaced wholesale by the phone, which owns the list. A show that is gone
+// from the list stops being polled, but its files are the mirror's business to clean up.
+func TestSetFeedsReplacesTheSubscriptionList(t *testing.T) {
+ s := New()
+ s.Merge("https://a", "A", nil)
+ s.Merge("https://b", "B", nil)
+
+ removed := s.SetFeeds([]string{"https://b", "https://c"})
+
+ if len(removed) != 1 || removed[0] != "https://a" {
+ t.Errorf("removed %v, want [https://a]", removed)
+ }
+ got := s.Feeds()
+ if len(got) != 2 {
+ t.Errorf("feeds %v, want 2", got)
+ }
+}
+
+func TestSaveAndLoadRoundTripsEverything(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "state.json")
+
+ s := New()
+ s.SetFeeds([]string{"https://f"})
+ s.Merge("https://f", "A Show", []feed.Episode{ep("g1", "One", "https://cdn/1.mp3", 100)})
+ e := s.Match("https://f", "g1", "", "")
+ e.Path = "audio/abc.mp3"
+ intro := int64(16652)
+ e.IntroMs = &intro
+
+ if err := s.Save(path); err != nil {
+ t.Fatal(err)
+ }
+ loaded, err := Load(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ got := loaded.Match("https://f", "g1", "", "")
+ if got == nil {
+ t.Fatal("episode did not survive the round trip")
+ }
+ if got.Path != "audio/abc.mp3" || got.IntroMs == nil || *got.IntroMs != 16652 {
+ t.Errorf("round trip lost data: %+v", got)
+ }
+ if len(loaded.Feeds()) != 1 {
+ t.Errorf("feeds did not survive: %v", loaded.Feeds())
+ }
+}
+
+// A missing state file is a first run, not a failure.
+func TestLoadingAMissingFileGivesAnEmptyState(t *testing.T) {
+ s, err := Load(filepath.Join(t.TempDir(), "absent.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(s.Feeds()) != 0 {
+ t.Errorf("expected no feeds, got %v", s.Feeds())
+ }
+}
+
+// Saving must not be able to truncate a good state file into a broken one.
+func TestSaveIsAtomic(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "state.json")
+ s := New()
+ s.SetFeeds([]string{"https://f"})
+ if err := s.Save(path); err != nil {
+ t.Fatal(err)
+ }
+
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, entry := range entries {
+ if entry.Name() != "state.json" {
+ t.Errorf("left a stray file behind: %s", entry.Name())
+ }
+ }
+}