summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-10-17 07:37:59 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-10-17 07:37:59 +0000
commitbd2a8ba9b681097dafd443695d9a7ebf22c7570d (patch)
tree9857b2200c229af4eb98118af0136a6c31138af6
parent203e8a6d9e2392f991936b2a6dbdc7dc05cdc3c9 (diff)
Wait for the server's copy instead of silently taking the publisher's
With a skip server configured, a podcast episode it cannot supply yet (pending, unreachable, bad answer, or audio that fails) now enters WAITING_FOR_SERVER and is retried on every refresh, instead of falling back to the publisher's differently-ad- inserted file. Taking the publisher's copy is an explicit action on the episode screen that sets audioFromServer=false. No server configured, YouTube/Facebook and unresolvable feeds still download directly. No schema change: downloadState is stored by name as TEXT. detekt: EpisodeDao and ListenViewModel function-count thresholds raised by one (rationale in detekt.yml). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
-rw-r--r--.coverage-baseline2
-rw-r--r--app/src/main/java/org/terst/listen/data/Daos.kt13
-rw-r--r--app/src/main/java/org/terst/listen/data/Entities.kt9
-rw-r--r--app/src/main/java/org/terst/listen/data/Repositories.kt7
-rw-r--r--app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt10
-rw-r--r--app/src/main/java/org/terst/listen/download/EpisodeDownloader.kt101
-rw-r--r--app/src/main/java/org/terst/listen/download/WaitingDownloads.kt73
-rw-r--r--app/src/main/java/org/terst/listen/download/WaitingForServerText.kt42
-rw-r--r--app/src/main/java/org/terst/listen/fingerprint/SkipSummary.kt5
-rw-r--r--app/src/main/java/org/terst/listen/server/ServerSkips.kt3
-rw-r--r--app/src/main/java/org/terst/listen/server/SkipServer.kt10
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeDetailActions.kt5
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeRow.kt11
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt15
-rw-r--r--app/src/main/java/org/terst/listen/ui/ListenViewModel.kt20
-rw-r--r--app/src/test/java/org/terst/listen/data/ConvertersTest.kt19
-rw-r--r--app/src/test/java/org/terst/listen/data/FakeDaos.kt5
-rw-r--r--app/src/test/java/org/terst/listen/data/SubscriptionRepositoryTest.kt21
-rw-r--r--app/src/test/java/org/terst/listen/download/EpisodeDownloaderTest.kt158
-rw-r--r--app/src/test/java/org/terst/listen/download/WaitForServerWiringTest.kt123
-rw-r--r--app/src/test/java/org/terst/listen/download/WaitingDownloadsTest.kt198
-rw-r--r--app/src/test/java/org/terst/listen/download/WaitingForServerTextTest.kt35
-rw-r--r--app/src/test/java/org/terst/listen/fingerprint/SkipSummaryTest.kt16
-rw-r--r--app/src/test/java/org/terst/listen/server/ServerSkipsTest.kt22
-rw-r--r--app/src/test/java/org/terst/listen/server/SkipRefresherTest.kt6
-rw-r--r--app/src/test/java/org/terst/listen/server/SkipServerTest.kt9
-rw-r--r--config/detekt/detekt.yml11
27 files changed, 878 insertions, 71 deletions
diff --git a/.coverage-baseline b/.coverage-baseline
index 4b940a3..7de30c1 100644
--- a/.coverage-baseline
+++ b/.coverage-baseline
@@ -1 +1 @@
-93.89
+94.16
diff --git a/app/src/main/java/org/terst/listen/data/Daos.kt b/app/src/main/java/org/terst/listen/data/Daos.kt
index 6a67184..2ab6041 100644
--- a/app/src/main/java/org/terst/listen/data/Daos.kt
+++ b/app/src/main/java/org/terst/listen/data/Daos.kt
@@ -301,6 +301,19 @@ interface EpisodeDao {
queued: DownloadState = DownloadState.QUEUED,
): List<EpisodeEntity>
+ /**
+ * Episodes waiting on the skip server's copy, newest first so a bounded retry spends its
+ * budget on what is most likely still wanted.
+ */
+ @Query(
+ """SELECT * FROM episodes WHERE downloadState = :waiting
+ ORDER BY COALESCE(publishedAtMs, discoveredAtMs) DESC LIMIT :limit"""
+ )
+ suspend fun waitingForServer(
+ limit: Int,
+ waiting: DownloadState = DownloadState.WAITING_FOR_SERVER,
+ ): List<EpisodeEntity>
+
/** Candidates for retention: played, downloaded, and past their grace period. */
@Query(
"""SELECT * FROM episodes
diff --git a/app/src/main/java/org/terst/listen/data/Entities.kt b/app/src/main/java/org/terst/listen/data/Entities.kt
index ce160b5..8db5a8b 100644
--- a/app/src/main/java/org/terst/listen/data/Entities.kt
+++ b/app/src/main/java/org/terst/listen/data/Entities.kt
@@ -31,7 +31,14 @@ enum class SubscriptionKind {
val needsExtraction: Boolean get() = this != PODCAST
}
-enum class DownloadState { NONE, QUEUED, DOWNLOADING, DOWNLOADED, FAILED }
+/**
+ * Stored by name in a TEXT column (see `Converters`), so adding a value needs no migration.
+ *
+ * [WAITING_FOR_SERVER] is not a failure: the skip server is configured but cannot hand over its
+ * copy of the audio yet, and the next refresh will ask again. Falling back to the publisher's
+ * copy is a choice the listener makes, never something done on their behalf.
+ */
+enum class DownloadState { NONE, QUEUED, DOWNLOADING, DOWNLOADED, FAILED, WAITING_FOR_SERVER }
@Entity(
tableName = "subscriptions",
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 ad1cfd7..476a7e3 100644
--- a/app/src/main/java/org/terst/listen/data/Repositories.kt
+++ b/app/src/main/java/org/terst/listen/data/Repositories.kt
@@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.first
import okhttp3.OkHttpClient
import org.terst.listen.server.OkHttpSkipTransport
import org.terst.listen.server.ServerSkips
+import org.terst.listen.download.WaitingDownloads
import org.terst.listen.server.SkipRefresher
import org.terst.listen.server.SkipServer
import org.terst.listen.server.SkipServerConfig
@@ -153,6 +154,7 @@ class AppGraph(private val context: Context) {
probe = { url -> ChaquopyAudioSource(context).probe(url) },
onRefreshed = { feedUrls -> skipServer.pushSubscriptions(feedUrls) },
refreshSkips = { skipRefresher.refreshAll() },
+ retryWaiting = { waitingDownloads.retryAll() },
),
)
}
@@ -199,6 +201,11 @@ class AppGraph(private val context: Context) {
)
}
+ /** Retries episodes waiting for the server's copy; fired by every refresh. */
+ val waitingDownloads: WaitingDownloads by lazy {
+ WaitingDownloads(episodes, subscriptions) { episode, kind -> downloader.download(episode, kind) }
+ }
+
val retention: RetentionPolicy by lazy {
RetentionPolicy(gracePeriodMs = TimeUnit.DAYS.toMillis(1))
}
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 1281b43..933c838 100644
--- a/app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt
+++ b/app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt
@@ -72,6 +72,13 @@ data class SubscriptionCollaborators(
* refresh's results.
*/
val refreshSkips: suspend () -> Unit = {},
+ /**
+ * Retries downloads waiting for the skip server's copy.
+ *
+ * Fired from `refreshAll` for the same reason as [refreshSkips]: the choke point every
+ * refresh route passes through, the library's refresh button included.
+ */
+ val retryWaiting: suspend () -> Unit = {},
)
class SubscriptionRepository(
@@ -149,6 +156,9 @@ class SubscriptionRepository(
// improve with no new episodes published at all, since the server keeps re-running
// detection on its own schedule.
collaborators.refreshSkips()
+ // Last, so a refresh that has just told the server about a new feed gives it the
+ // longest head start before the first retry asks for that feed's episodes.
+ collaborators.retryWaiting()
return outcomes
}
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 1479812..dfc4aad 100644
--- a/app/src/main/java/org/terst/listen/download/EpisodeDownloader.kt
+++ b/app/src/main/java/org/terst/listen/download/EpisodeDownloader.kt
@@ -29,7 +29,18 @@ import java.io.IOException
* database writes do not.
*/
interface ServerAudio {
- /** What the server has for this episode, or null to download from the publisher. */
+ /**
+ * Whether there is a server to ask about this episode at all: one is configured, and the
+ * episode's feed can be identified to it.
+ *
+ * Not the same question as whether [resolve] has an answer. `resolve` returns null both when
+ * nothing is configured and when the server is merely unreachable, and the downloader must
+ * treat those oppositely -- nothing configured means the publisher's copy is the only copy
+ * there is, while an unreachable server means its copy is coming.
+ */
+ suspend fun canAsk(episode: EpisodeEntity): Boolean
+
+ /** What the server has for this episode, or null if it has no answer right now. */
suspend fun resolve(episode: EpisodeEntity): ServerEpisode?
/** Records the server's offsets, once its audio is actually the file on disk. */
@@ -42,7 +53,8 @@ class EpisodeDownloader(
private val libraryDir: File,
private val extractor: ExtractedAudioSource? = null,
/**
- * The skip server, preferred over the publisher when it has the episode.
+ * The skip server. When one is configured, its copy is the only one downloaded
+ * automatically; an episode it cannot supply yet waits (see [WaitingForServerException]).
*
* 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
@@ -86,11 +98,45 @@ class EpisodeDownloader(
// localPath is cleared: a FAILED row pointing at a path invites something downstream
// to try playing a file that is not there. audioFromServer defaults to false on this
// call, clearing any stale claim from an earlier download the same way.
- onFailure = { episodes.updateDownload(episode.id, DownloadState.FAILED, null, null) },
+ //
+ // A wait is not a failure and has its own state: FAILED means the download itself
+ // went wrong and has its own UI, where this is an episode that will be fetched on a
+ // later refresh with no one needing to do anything.
+ onFailure = {
+ val state = if (it is WaitingForServerException) {
+ DownloadState.WAITING_FOR_SERVER
+ } else {
+ DownloadState.FAILED
+ }
+ episodes.updateDownload(episode.id, state, null, null)
+ },
)
return result.map { it.file }
}
+ /**
+ * The listener's deliberate choice to take the publisher's copy of a podcast episode instead
+ * of waiting for the server's.
+ *
+ * Always recorded as not the server's: the publisher serves a different file on every
+ * request, so the skips measured on the server's copy do not apply to it, and
+ * `audioFromServer = false` is what stops the skip-refresh sweep stamping them on anyway.
+ * A failure here is a plain FAILED -- the listener asked for this, so it should show.
+ */
+ suspend fun downloadFromPublisher(episode: EpisodeEntity): Result<File> {
+ episodes.updateDownload(episode.id, DownloadState.DOWNLOADING, null, null)
+ val result = downloadOverHttp(episode, episode.mediaUrl)
+ result.fold(
+ onSuccess = {
+ episodes.updateDownload(
+ episode.id, DownloadState.DOWNLOADED, it.absolutePath, now(), audioFromServer = false,
+ )
+ },
+ onFailure = { episodes.updateDownload(episode.id, DownloadState.FAILED, null, null) },
+ )
+ return result
+ }
+
/** What actually landed on disk, and whether it was the skip server's own copy of it. */
private data class AudioResult(val file: File, val fromServer: Boolean)
@@ -105,25 +151,35 @@ class EpisodeDownloader(
}
/**
- * Prefers the skip server, falling back to the publisher.
+ * Takes the server's copy, or waits for it; the publisher's copy only when there is no
+ * server to ask.
*
- * 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.
+ * The publisher serves a different file on every request, so skips measured on the server's
+ * copy are wrong for anything else. Falling back silently therefore produced an episode whose
+ * skips could never apply, and the owner has ruled that out: with a server configured, an
+ * episode it cannot supply yet (pending, unreachable, a bad token, an unreadable answer, or
+ * audio that then fails to arrive) fails with [WaitingForServerException] and is retried on
+ * the next refresh. Taking the publisher's copy is [downloadFromPublisher], on request.
+ *
+ * With no server configured, or no feed to identify the episode to it, there is nothing to
+ * wait for, and this downloads from the publisher exactly as the app did before the server
+ * existed. Offsets are recorded only when the server's audio is what landed on disk.
*/
private suspend fun downloadPodcast(episode: EpisodeEntity): Result<AudioResult> {
- 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.map { AudioResult(it, fromServer = true) }
- }
+ val server = serverAudio
+ if (server == null || !server.canAsk(episode)) {
+ return downloadOverHttp(episode, episode.mediaUrl).map { AudioResult(it, fromServer = false) }
}
- return downloadOverHttp(episode, episode.mediaUrl).map { AudioResult(it, fromServer = false) }
+
+ val info = server.resolve(episode)
+ val serverUrl = info?.audioUrl?.takeIf { info.ready }
+ ?: return Result.failure(WaitingForServerException())
+
+ val result = downloadOverHttp(episode, serverUrl)
+ if (result.isFailure) return Result.failure(WaitingForServerException(result.exceptionOrNull()))
+
+ server.record(episode, info)
+ return result.map { AudioResult(it, fromServer = true) }
}
private suspend fun downloadViaExtractor(episode: EpisodeEntity): Result<File> {
@@ -210,6 +266,15 @@ class EpisodeDownloader(
}
}
+/**
+ * The skip server is configured but cannot supply this episode's audio yet.
+ *
+ * Carried as a failed [Result] so existing callers need no new return type, but never stored as
+ * a failure: [EpisodeDownloader.download] records it as `WAITING_FOR_SERVER`.
+ */
+class WaitingForServerException(cause: Throwable? = null) :
+ IOException("Waiting for the server's copy; will retry on the next refresh", cause)
+
/** Indirection so the Chaquopy-backed downloader can be swapped out in tests. */
interface ExtractedAudioSource {
suspend fun download(url: String, targetDir: File): File
diff --git a/app/src/main/java/org/terst/listen/download/WaitingDownloads.kt b/app/src/main/java/org/terst/listen/download/WaitingDownloads.kt
new file mode 100644
index 0000000..bfbfbd9
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/download/WaitingDownloads.kt
@@ -0,0 +1,73 @@
+package org.terst.listen.download
+
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.sync.Semaphore
+import kotlinx.coroutines.sync.withPermit
+import org.terst.listen.data.EpisodeDao
+import org.terst.listen.data.EpisodeEntity
+import org.terst.listen.data.SubscriptionDao
+import org.terst.listen.data.SubscriptionKind
+import java.io.File
+import java.util.concurrent.atomic.AtomicInteger
+
+/**
+ * Retries the downloads that are waiting for the skip server's copy, on a feed refresh.
+ *
+ * An episode lands in `WAITING_FOR_SERVER` when a server is configured but could not supply its
+ * audio yet. The server has started fetching it, so asking again later usually succeeds; this is
+ * that later. Run from `SubscriptionRepository.refreshAll` -- the one choke point every refresh
+ * route passes through, the library's refresh button included.
+ *
+ * Unlike [org.terst.listen.server.SkipRefresher] each retry may be a real, tens-of-megabytes
+ * download, so it is bounded twice: [CONCURRENT] at a time, and at most [MAX_PER_REFRESH] per
+ * refresh, newest first. The rest simply wait for the following refresh.
+ */
+class WaitingDownloads(
+ private val episodes: EpisodeDao,
+ private val subscriptions: SubscriptionDao,
+ private val download: suspend (EpisodeEntity, SubscriptionKind) -> Result<File>,
+) {
+
+ /**
+ * Returns how many waiting episodes were downloaded.
+ *
+ * One episode's failure must not stop the rest, so each is isolated; [CancellationException]
+ * is the one throwable deliberately not caught, or a cancelled refresh would keep running --
+ * the bug already fixed once in `skipFurniture()`. `coroutineScope` keeps every retry a
+ * structured child of this call.
+ */
+ suspend fun retryAll(): Int = coroutineScope {
+ val semaphore = Semaphore(CONCURRENT)
+ val downloaded = AtomicInteger()
+ episodes.waitingForServer(MAX_PER_REFRESH).map { episode ->
+ async {
+ semaphore.withPermit {
+ // Deliberately broad, for the reason above. Logged rather than discarded.
+ @Suppress("TooGenericExceptionCaught")
+ try {
+ // A show that has gone has no kind to download by; guessing PODCAST could
+ // fetch a watch page over plain HTTP.
+ val kind = subscriptions.getById(episode.subscriptionId)?.kind
+ if (kind != null && download(episode, kind).isSuccess) downloaded.incrementAndGet()
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ android.util.Log.w("WaitingDownloads", "retry failed for ${episode.id}", e)
+ }
+ }
+ }
+ }.awaitAll()
+ downloaded.get()
+ }
+
+ companion object {
+ /** Downloads are heavy; a couple at once is plenty and keeps the refresh button quick. */
+ const val CONCURRENT = 2
+
+ /** One refresh's worth, like [org.terst.listen.work.DownloadDrainer]'s batch. */
+ const val MAX_PER_REFRESH = 10
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/download/WaitingForServerText.kt b/app/src/main/java/org/terst/listen/download/WaitingForServerText.kt
new file mode 100644
index 0000000..70c68c2
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/download/WaitingForServerText.kt
@@ -0,0 +1,42 @@
+package org.terst.listen.download
+
+import org.terst.listen.data.DownloadState
+import org.terst.listen.data.EpisodeEntity
+
+/**
+ * Everything the app says about an episode waiting for the skip server's copy, in one testable
+ * place rather than inside composables -- the same arrangement as `SkipSummary`, whose readout
+ * tests pin this wording too.
+ */
+object WaitingForServerText {
+
+ /** The episode screen's readout while an episode waits on the server's copy. */
+ const val STATUS = "Waiting for the server's copy; will retry on the next refresh"
+
+ /** The one-line list-row tag for the same state. */
+ const val ROW_LABEL = "waiting for server"
+
+ /** What the snackbar says after tapping download on a waiting episode that still is not ready. */
+ const val BANNER = "The server doesn't have this one yet. It will be retried on the next refresh."
+
+ const val PUBLISHER_ACTION_LABEL = "Download from publisher instead"
+
+ /**
+ * The trade, said plainly: ads are inserted per download, so the publisher's file is not the
+ * one the skips were measured on.
+ */
+ const val PUBLISHER_ACTION_WARNING =
+ "The publisher's file can differ from the server's copy, because ads are inserted per " +
+ "download. The skips were measured on the server's copy, so they will not apply to this one."
+
+ /** The explicit way out of a wait: label for the button, warning shown beside it. */
+ data class PublisherOffer(val label: String, val warning: String)
+
+ /** Offered only while the episode is waiting; any other state has nothing to choose. */
+ fun publisherOffer(episode: EpisodeEntity): PublisherOffer? =
+ if (episode.downloadState == DownloadState.WAITING_FOR_SERVER) {
+ PublisherOffer(PUBLISHER_ACTION_LABEL, PUBLISHER_ACTION_WARNING)
+ } else {
+ null
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/fingerprint/SkipSummary.kt b/app/src/main/java/org/terst/listen/fingerprint/SkipSummary.kt
index 0c0a4e3..9c2576d 100644
--- a/app/src/main/java/org/terst/listen/fingerprint/SkipSummary.kt
+++ b/app/src/main/java/org/terst/listen/fingerprint/SkipSummary.kt
@@ -1,5 +1,6 @@
package org.terst.listen.fingerprint
+import org.terst.listen.download.WaitingForServerText
import org.terst.listen.data.DownloadState
import org.terst.listen.data.EpisodeEntity
import org.terst.listen.data.EpisodeMidrollEntity
@@ -183,6 +184,10 @@ object SkipSummary {
outro = offset(episode.detectedOutroMs, show?.skipOutroMs ?: 0),
breaks = when {
breaks.isNotEmpty() -> breaks
+ // Nothing has been downloaded yet, so the skips cannot have been checked either;
+ // say what is actually happening instead of "Not checked yet".
+ episode.downloadState == DownloadState.WAITING_FOR_SERVER ->
+ listOf(BreakLine("Breaks", WaitingForServerText.STATUS))
// A download that is not the server's own copy can never be refreshed in
// place -- a show with dynamic ad insertion serves a different file on every
// request, so the server's timestamps only mean anything against its own
diff --git a/app/src/main/java/org/terst/listen/server/ServerSkips.kt b/app/src/main/java/org/terst/listen/server/ServerSkips.kt
index aec261b..2a182cf 100644
--- a/app/src/main/java/org/terst/listen/server/ServerSkips.kt
+++ b/app/src/main/java/org/terst/listen/server/ServerSkips.kt
@@ -23,6 +23,9 @@ class ServerSkips(
private val midrolls: MidrollDao,
) : ServerAudio {
+ override suspend fun canAsk(episode: EpisodeEntity): Boolean =
+ server.isConfigured() && subscriptions.getById(episode.subscriptionId) != null
+
/**
* Asks the server about an episode, identifying it the way the server keys them.
*
diff --git a/app/src/main/java/org/terst/listen/server/SkipServer.kt b/app/src/main/java/org/terst/listen/server/SkipServer.kt
index a1d3ccc..69dbeec 100644
--- a/app/src/main/java/org/terst/listen/server/SkipServer.kt
+++ b/app/src/main/java/org/terst/listen/server/SkipServer.kt
@@ -96,6 +96,16 @@ class SkipServer(
) {
/**
+ * Whether an address and token are set at all.
+ *
+ * Separate from [episode] returning null because that null also covers an unreachable server,
+ * and the download path must treat the two oppositely: no server means download from the
+ * publisher as the app always did, while a server that merely did not answer means wait.
+ */
+ suspend fun isConfigured(): Boolean = config() != null
+
+
+ /**
* Tells the server what the phone subscribes to.
*
* The phone owns this list: everything already built around it -- the share sheet, OPML
diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeDetailActions.kt b/app/src/main/java/org/terst/listen/ui/EpisodeDetailActions.kt
index 009c8c0..a108e04 100644
--- a/app/src/main/java/org/terst/listen/ui/EpisodeDetailActions.kt
+++ b/app/src/main/java/org/terst/listen/ui/EpisodeDetailActions.kt
@@ -41,6 +41,11 @@ data class EpisodeDetailActions(
val play: () -> Unit,
val enqueue: () -> Unit,
val download: () -> Unit,
+ /**
+ * Takes the publisher's copy of a waiting episode. Deliberately its own action rather than
+ * a mode of [download], so it can only be reached from the button that states the trade.
+ */
+ val downloadFromPublisher: () -> Unit = {},
val togglePlayed: () -> Unit,
val markOlderPlayed: () -> Unit,
/** Play/pause when this episode is the one playing; starts it when it is not. */
diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeRow.kt b/app/src/main/java/org/terst/listen/ui/EpisodeRow.kt
index 3574d25..dd91e18 100644
--- a/app/src/main/java/org/terst/listen/ui/EpisodeRow.kt
+++ b/app/src/main/java/org/terst/listen/ui/EpisodeRow.kt
@@ -17,6 +17,7 @@ import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.PlaylistAdd
import androidx.compose.material.icons.filled.PlaylistPlay
+import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.outlined.CheckCircle
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
@@ -31,6 +32,7 @@ import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import org.terst.listen.data.DownloadState
import org.terst.listen.data.EpisodeEntity
+import org.terst.listen.download.WaitingForServerText
import org.terst.listen.data.playbackDurationMs
import java.util.concurrent.TimeUnit
@@ -109,6 +111,14 @@ private fun DownloadIcon(state: DownloadState, onDownload: () -> Unit) {
tint = MaterialTheme.colorScheme.primary,
)
+ DownloadState.WAITING_FOR_SERVER ->
+ IconButton(onClick = onDownload) {
+ Icon(
+ Icons.Default.Schedule,
+ contentDescription = "Waiting for the server's copy, tap to try now",
+ )
+ }
+
DownloadState.FAILED ->
IconButton(onClick = onDownload) {
Icon(
@@ -132,6 +142,7 @@ internal fun EpisodeEntity.progressLabel(): String {
if (positionMs > 0) parts += "${formatDuration(positionMs)} in"
if (playedAtMs != null) parts += "played"
if (downloadState == DownloadState.FAILED) parts += "download failed"
+ if (downloadState == DownloadState.WAITING_FOR_SERVER) parts += WaitingForServerText.ROW_LABEL
return parts.joinToString(" · ")
}
diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt b/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt
index 70f4a77..b3a7c96 100644
--- a/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt
+++ b/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt
@@ -36,6 +36,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -45,7 +46,9 @@ import androidx.compose.ui.geometry.Size
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
+import kotlinx.coroutines.launch
import org.terst.listen.data.EpisodeEntity
+import org.terst.listen.download.WaitingForServerText
import org.terst.listen.data.EpisodeMidrollEntity
import org.terst.listen.data.SubscriptionEntity
import org.terst.listen.data.playbackDurationMs
@@ -77,6 +80,7 @@ fun EpisodeScreen(
episode = vm.episode(episodeId)
ranges = vm.midrollRanges(episodeId)
}
+ val scope = rememberCoroutineScope()
val rules by vm.rules.collectAsState()
val shows by vm.subscriptions.collectAsState()
val nowPlaying by player.nowPlaying.collectAsState()
@@ -108,6 +112,8 @@ fun EpisodeScreen(
play = { player.play(current); nav.openPlayer() },
enqueue = { vm.enqueue(current.id) },
download = { vm.download(current) },
+ // Re-reads afterwards, or the screen keeps offering the choice it just acted on.
+ downloadFromPublisher = { vm.downloadFromPublisher(current) { scope.launch { episode = vm.episode(episodeId) } } },
togglePlayed = {
if (current.playedAtMs == null) vm.markPlayed(current.id) else vm.markUnplayed(current.id)
episode = current.copy(playedAtMs = if (current.playedAtMs == null) 1L else null)
@@ -172,6 +178,15 @@ private fun EpisodeDetail(
WatchButton(WatchLink.at(episode.mediaUrl, episode.positionMs), "Watch")
}
+ // The way out of a wait, and what it costs, side by side. Wording is SkipSummary's so a
+ // test pins it.
+ WaitingForServerText.publisherOffer(episode)?.let { offer ->
+ Text(offer.warning, style = MaterialTheme.typography.bodySmall)
+ OutlinedButton(onClick = actions.downloadFromPublisher, modifier = Modifier.fillMaxWidth()) {
+ Text(offer.label)
+ }
+ }
+
// The import case: a freshly imported back catalogue is entirely unplayed, and this is
// the only practical way to say "I've heard everything up to here".
OutlinedButton(onClick = actions.markOlderPlayed, modifier = Modifier.fillMaxWidth()) {
diff --git a/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt b/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt
index d03c7a5..4365729 100644
--- a/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt
+++ b/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt
@@ -1,5 +1,6 @@
package org.terst.listen.ui
+import org.terst.listen.download.WaitingForServerException
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
@@ -12,6 +13,7 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.terst.listen.data.EpisodeEntity
+import org.terst.listen.download.WaitingForServerText
import org.terst.listen.data.Graph
import org.terst.listen.data.Preferences
import org.terst.listen.data.RefreshOutcome
@@ -218,8 +220,26 @@ class ListenViewModel(app: Application) : AndroidViewModel(app) {
val subscription = graph.subscriptions.getById(episode.subscriptionId)
val kind = subscription?.kind ?: SubscriptionKind.PODCAST
graph.downloader.download(episode, kind).onFailure {
+ // Not an error: the episode is now waiting and the next refresh retries it.
+ _banner.value = if (it is WaitingForServerException) {
+ Banner(WaitingForServerText.BANNER)
+ } else {
+ Banner("Download failed: ${it.message}", isError = true)
+ }
+ }
+ }
+ }
+
+ /**
+ * The listener's explicit choice to take the publisher's copy of a waiting episode. The
+ * screen shows the trade (see [WaitingForServerText.publisherOffer]) before this can be tapped.
+ */
+ fun downloadFromPublisher(episode: EpisodeEntity, onDone: () -> Unit = {}) {
+ viewModelScope.launch {
+ graph.downloader.downloadFromPublisher(episode).onFailure {
_banner.value = Banner("Download failed: ${it.message}", isError = true)
}
+ onDone()
}
}
diff --git a/app/src/test/java/org/terst/listen/data/ConvertersTest.kt b/app/src/test/java/org/terst/listen/data/ConvertersTest.kt
new file mode 100644
index 0000000..1387799
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/data/ConvertersTest.kt
@@ -0,0 +1,19 @@
+package org.terst.listen.data
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+/**
+ * [DownloadState] is stored by name, which is why adding [DownloadState.WAITING_FOR_SERVER]
+ * needs no schema migration: the column is plain TEXT and nothing constrains its values.
+ */
+class ConvertersTest {
+ @Test
+ fun `every download state survives a round trip through its stored form`() {
+ val converters = Converters()
+ DownloadState.entries.forEach {
+ assertEquals(it, converters.toDownloadState(converters.fromDownloadState(it)))
+ }
+ assertEquals("WAITING_FOR_SERVER", converters.fromDownloadState(DownloadState.WAITING_FOR_SERVER))
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/data/FakeDaos.kt b/app/src/test/java/org/terst/listen/data/FakeDaos.kt
index c9937fb..9515c3d 100644
--- a/app/src/test/java/org/terst/listen/data/FakeDaos.kt
+++ b/app/src/test/java/org/terst/listen/data/FakeDaos.kt
@@ -237,6 +237,11 @@ class FakeEpisodeDao : EpisodeDao {
override suspend fun downloadedFromServer(downloaded: DownloadState) =
rows.filter { it.downloadState == downloaded && it.audioFromServer }
+ override suspend fun waitingForServer(limit: Int, waiting: DownloadState) =
+ rows.filter { it.downloadState == waiting }
+ .sortedByDescending { it.publishedAtMs ?: it.discoveredAtMs }
+ .take(limit)
+
override suspend fun setChapters(id: String, json: String?) {
replace(id) { it.copy(chaptersJson = json) }
}
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 63ac219..1c8cda1 100644
--- a/app/src/test/java/org/terst/listen/data/SubscriptionRepositoryTest.kt
+++ b/app/src/test/java/org/terst/listen/data/SubscriptionRepositoryTest.kt
@@ -570,4 +570,25 @@ class SubscriptionRepositoryTest {
assertEquals(1, calls)
}
+
+ /** Same choke point, same reason: the refresh button must retry waiting downloads too. */
+ @Test
+ fun `refreshAll retries waiting downloads exactly once, after the refresh`() = runTest {
+ var calls = 0
+ val repoWithRetry = SubscriptionRepository(
+ subs,
+ eps,
+ FeedFetcher(OkHttpClient()),
+ SubscriptionCollaborators(retryWaiting = { calls++ }),
+ ) { clock }
+
+ server.enqueue(MockResponse().setBody(feed()))
+ repoWithRetry.subscribe(url())
+ assertEquals("subscribing is not a refresh", 0, calls)
+
+ server.enqueue(MockResponse().setBody(feed()))
+ repoWithRetry.refreshAll()
+
+ assertEquals(1, calls)
+ }
}
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 d524200..46e15c0 100644
--- a/app/src/test/java/org/terst/listen/download/EpisodeDownloaderTest.kt
+++ b/app/src/test/java/org/terst/listen/download/EpisodeDownloaderTest.kt
@@ -171,8 +171,13 @@ class EpisodeDownloaderTest {
// 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 {
+ private class FakeServerAudio(
+ private val info: ServerEpisode?,
+ /** Whether a server is configured at all -- a different thing from it having no answer. */
+ private val configured: Boolean = true,
+ ) : ServerAudio {
var recorded: Pair<String, ServerEpisode>? = null
+ override suspend fun canAsk(episode: EpisodeEntity) = configured
override suspend fun resolve(episode: EpisodeEntity) = info
override suspend fun record(episode: EpisodeEntity, info: ServerEpisode) {
recorded = episode.id to info
@@ -224,19 +229,21 @@ class EpisodeDownloaderTest {
}
@Test
- fun `downloads from the publisher when the server has nothing yet`() = runTest {
+ fun `downloads from the publisher at once when no server is configured`() = 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)
+ // resolve() answers null here exactly as it does for an unreachable server; only
+ // canAsk tells the two apart, which is what this test pins.
+ val result = withServer(FakeServerAudio(null, configured = false)).download(ep, SubscriptionKind.PODCAST)
assertTrue(result.isSuccess)
assertEquals("/original.mp3", server.takeRequest().path)
+ assertEquals(DownloadState.DOWNLOADED, dao.rows.single().downloadState)
}
@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 }
@@ -245,44 +252,117 @@ class EpisodeDownloaderTest {
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.
- */
+ // ---- waiting for the server's copy, instead of silently taking the publisher's ----
+ //
+ // The publisher serves a different file on every request, so a silent fallback produces an
+ // episode whose skips cannot apply. With a server configured the episode waits; taking the
+ // publisher's copy is the listener's call (downloadFromPublisher).
+
+ private fun pending() = ServerEpisode(
+ ready = false, audioUrl = null, durationMs = null, introMs = null, outroMs = null, detected = false,
+ )
+
+ private fun brokenReady() = ServerEpisode(
+ ready = true, audioUrl = server.url("/audio/broken").toString(),
+ durationMs = null, introMs = 1, outroMs = 2, detected = true,
+ )
+
+ private fun assertWaiting(result: Result<File>) {
+ assertTrue("a wait is reported as not-yet-downloaded", result.isFailure)
+ val stored = dao.rows.single()
+ assertEquals(DownloadState.WAITING_FOR_SERVER, stored.downloadState)
+ assertNull(stored.localPath)
+ assertFalse(stored.audioFromServer)
+ assertEquals("nothing should be left in the library", 0, library.listFiles()!!.size)
+ }
+
+ @Test
+ fun `a pending server copy waits and never touches the publisher`() = runTest {
+ val ep = episode(url = server.url("/original.mp3").toString()).also { dao.rows += it }
+
+ val result = withServer(FakeServerAudio(pending())).download(ep, SubscriptionKind.PODCAST)
+
+ assertWaiting(result)
+ assertEquals("the publisher must not be contacted", 0, server.requestCount)
+ }
+
@Test
- fun `falls back to the publisher when the server's own audio fails`() = runTest {
+ fun `no answer from a configured server waits and never touches the publisher`() = runTest {
+ val ep = episode(url = server.url("/original.mp3").toString()).also { dao.rows += it }
+
+ val result = withServer(FakeServerAudio(null, configured = true)).download(ep, SubscriptionKind.PODCAST)
+
+ assertWaiting(result)
+ assertEquals(0, server.requestCount)
+ }
+
+ @Test
+ fun `a ready server whose audio then fails waits, is not failed, and skips the publisher`() = 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 audio = FakeServerAudio(brokenReady())
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())
+ assertWaiting(result)
+ assertEquals(1, server.requestCount)
assertEquals("/audio/broken", server.takeRequest().path)
- assertEquals("/original.mp3", server.takeRequest().path)
+ assertNull("offsets describe the server's copy, which never landed", audio.recorded)
}
- /** 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))
+ fun `an extracted video never waits even when a server is configured`() = runTest {
+ val extractor = object : ExtractedAudioSource {
+ override suspend fun download(url: String, targetDir: File) =
+ File(targetDir, "v.m4a").also { it.writeText("video-audio") }
+
+ override suspend fun probe(url: String): org.terst.listen.data.ProbedVideo? = null
+ }
+ val audio = FakeServerAudio(null, configured = true)
+ val youtube = EpisodeDownloader(OkHttpClient(), dao, library, extractor, audio) { 1_000L }
+ val ep = episode(url = "https://www.youtube.com/watch?v=abc").also { dao.rows += it }
+
+ val result = youtube.download(ep, SubscriptionKind.YOUTUBE_CHANNEL)
+ assertTrue(result.isSuccess)
+ assertEquals(DownloadState.DOWNLOADED, dao.rows.single().downloadState)
+
+ val fb = episode(id = "fb", url = "https://facebook.com/watch?v=1").also { dao.rows += it }
+ assertTrue(youtube.download(fb, SubscriptionKind.FACEBOOK_VIDEO).isSuccess)
+ assertEquals(DownloadState.DOWNLOADED, dao.rows.single { it.id == "fb" }.downloadState)
+ }
+
+ // ---- the explicit choice --------------------------------------------------------------
+
+ @Test
+ fun `downloading from the publisher on request lands the file and says it is not the server's`() = runTest {
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 ep = episode(url = server.url("/original.mp3").toString())
+ .copy(downloadState = DownloadState.WAITING_FOR_SERVER)
+ .also { dao.rows += it }
+ val audio = FakeServerAudio(pending())
- withServer(audio).download(ep, SubscriptionKind.PODCAST)
+ val result = withServer(audio).downloadFromPublisher(ep)
- assertNull("recorded offsets for audio it did not download", audio.recorded)
+ assertTrue(result.isSuccess)
+ assertEquals("/original.mp3", server.takeRequest().path)
+ val stored = dao.rows.single()
+ assertEquals(DownloadState.DOWNLOADED, stored.downloadState)
+ assertFalse("the skip sweep keys off this", stored.audioFromServer)
+ assertEquals("publisher-bytes", File(stored.localPath!!).readText())
+ assertNull("the server was not consulted or recorded", audio.recorded)
+ }
+
+ @Test
+ fun `a failed publisher download on request is a failure the listener can see`() = runTest {
+ server.enqueue(MockResponse().setResponseCode(500))
+ val ep = episode(url = server.url("/original.mp3").toString())
+ .copy(downloadState = DownloadState.WAITING_FOR_SERVER)
+ .also { dao.rows += it }
+
+ val result = withServer(FakeServerAudio(pending())).downloadFromPublisher(ep)
+
+ assertTrue(result.isFailure)
+ assertEquals(DownloadState.FAILED, dao.rows.single().downloadState)
}
// ---- provenance: which file actually landed -----------------------------------------
@@ -312,28 +392,12 @@ class EpisodeDownloaderTest {
server.enqueue(MockResponse().setBody("publisher-bytes"))
val ep = episode(url = server.url("/original.mp3").toString()).also { dao.rows += it }
- withServer(FakeServerAudio(null)).download(ep, SubscriptionKind.PODCAST)
+ withServer(FakeServerAudio(null, configured = false)).download(ep, SubscriptionKind.PODCAST)
assertFalse("the publisher's own audio must not be marked as the server's",
dao.rows.single().audioFromServer)
}
- @Test
- fun `falling back to the publisher after the server fails is not marked as the server's`() = 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)
-
- assertFalse("the fallback file is the publisher's, not the server's",
- dao.rows.single().audioFromServer)
- }
-
/** A YouTube/Facebook download never goes near the skip server at all. */
@Test
fun `an extracted video download is not marked as server-sourced`() = runTest {
@@ -364,7 +428,7 @@ class EpisodeDownloaderTest {
File(dao.rows.single().localPath!!).delete()
server.enqueue(MockResponse().setBody("publisher-bytes"))
- withServer(FakeServerAudio(null)).download(dao.rows.single(), SubscriptionKind.PODCAST)
+ withServer(FakeServerAudio(null, configured = false)).download(dao.rows.single(), SubscriptionKind.PODCAST)
assertFalse(
"a previous download's provenance must not survive a re-download from elsewhere",
diff --git a/app/src/test/java/org/terst/listen/download/WaitForServerWiringTest.kt b/app/src/test/java/org/terst/listen/download/WaitForServerWiringTest.kt
new file mode 100644
index 0000000..6162cae
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/download/WaitForServerWiringTest.kt
@@ -0,0 +1,123 @@
+package org.terst.listen.download
+
+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.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import org.terst.listen.data.DownloadState
+import org.terst.listen.data.EpisodeEntity
+import org.terst.listen.data.FakeEpisodeDao
+import org.terst.listen.data.FakeMidrollDao
+import org.terst.listen.data.FakePlaybackStateDao
+import org.terst.listen.data.FakeShowFingerprintDao
+import org.terst.listen.data.FakeSubscriptionDao
+import org.terst.listen.data.SubscriptionEntity
+import org.terst.listen.data.SubscriptionKind
+import org.terst.listen.server.ServerSkips
+import org.terst.listen.server.SkipServer
+import org.terst.listen.server.SkipServerConfig
+import org.terst.listen.server.SkipTransport
+
+/**
+ * The real downloader over the real [ServerSkips] and [SkipServer], with only the HTTP faked.
+ *
+ * The unit tests above fake [ServerAudio] and so cannot see the distinction that matters most:
+ * "no server configured" and "server unreachable" both come out of the client as null. These
+ * run the actual client so a regression there -- the app waiting forever for a server nobody
+ * set up -- is caught where it would really happen.
+ */
+class WaitForServerWiringTest {
+
+ @get:Rule val temp = TemporaryFolder()
+
+ private lateinit var publisher: MockWebServer
+ private lateinit var dao: FakeEpisodeDao
+ private lateinit var subscriptions: FakeSubscriptionDao
+
+ private class Transport(private val body: String?) : SkipTransport {
+ var gets = 0
+ override suspend fun get(url: String, token: String): String? {
+ gets++
+ return body
+ }
+
+ override suspend fun post(url: String, token: String, body: String) = true
+ }
+
+ @Before
+ fun setUp() {
+ publisher = MockWebServer().apply { start() }
+ dao = FakeEpisodeDao()
+ subscriptions = FakeSubscriptionDao().apply {
+ rows += SubscriptionEntity(id = "s1", feedUrl = "https://feed", title = "Show", addedAtMs = 0)
+ }
+ }
+
+ @After
+ fun tearDown() = publisher.shutdown()
+
+ private fun episode(subscriptionId: String = "s1") = EpisodeEntity(
+ id = "e1", subscriptionId = subscriptionId, guid = "g", title = "T",
+ mediaUrl = publisher.url("/orig.mp3").toString(), mediaType = "audio/mpeg", discoveredAtMs = 0,
+ ).also { dao.rows += it }
+
+ private fun downloader(transport: Transport, configured: Boolean): EpisodeDownloader {
+ val skipServer = SkipServer(
+ { if (configured) SkipServerConfig("https://listen.example", "tok") else null },
+ transport,
+ )
+ val fingerprints = FakeShowFingerprintDao().apply { episodeRows = dao.rows }
+ val serverSkips = ServerSkips(skipServer, subscriptions, fingerprints, FakePlaybackStateDao(dao.rows), FakeMidrollDao())
+ return EpisodeDownloader(OkHttpClient(), dao, temp.newFolder(), null, serverSkips) { 1L }
+ }
+
+ @Test
+ fun `no server configured downloads from the publisher immediately`() = runTest {
+ publisher.enqueue(MockResponse().setBody("publisher-bytes"))
+ val transport = Transport(null)
+
+ val result = downloader(transport, configured = false).download(episode(), SubscriptionKind.PODCAST)
+
+ assertTrue(result.isSuccess)
+ assertEquals(DownloadState.DOWNLOADED, dao.rows.single().downloadState)
+ assertEquals("/orig.mp3", publisher.takeRequest().path)
+ assertEquals("an unconfigured server must not even be asked", 0, transport.gets)
+ }
+
+ @Test
+ fun `a missing feed url downloads from the publisher, there being nothing to ask`() = runTest {
+ publisher.enqueue(MockResponse().setBody("publisher-bytes"))
+
+ val result = downloader(Transport("""{"status":"pending"}"""), configured = true)
+ .download(episode(subscriptionId = "gone"), SubscriptionKind.PODCAST)
+
+ assertTrue(result.isSuccess)
+ assertEquals(DownloadState.DOWNLOADED, dao.rows.single().downloadState)
+ }
+
+ private fun assertWaitsWithoutPublisher(body: String?) = runTest {
+ val transport = Transport(body)
+
+ val result = downloader(transport, configured = true).download(episode(), SubscriptionKind.PODCAST)
+
+ assertTrue(result.isFailure)
+ assertEquals(DownloadState.WAITING_FOR_SERVER, dao.rows.single().downloadState)
+ assertEquals(1, transport.gets)
+ assertEquals("the publisher must not be contacted", 0, publisher.requestCount)
+ }
+
+ @Test fun `an unreachable server waits`() = assertWaitsWithoutPublisher(null)
+
+ @Test fun `a response that will not parse waits`() = assertWaitsWithoutPublisher("<html>502 bad gateway")
+
+ @Test fun `a pending episode waits`() = assertWaitsWithoutPublisher("""{"status":"pending","detected":false}""")
+
+ @Test fun `a ready status with no audio url waits`() = assertWaitsWithoutPublisher("""{"status":"ready"}""")
+}
diff --git a/app/src/test/java/org/terst/listen/download/WaitingDownloadsTest.kt b/app/src/test/java/org/terst/listen/download/WaitingDownloadsTest.kt
new file mode 100644
index 0000000..465af46
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/download/WaitingDownloadsTest.kt
@@ -0,0 +1,198 @@
+package org.terst.listen.download
+
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.runCurrent
+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.assertTrue
+import org.junit.Assert.fail
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import org.terst.listen.data.DownloadState
+import org.terst.listen.data.EpisodeEntity
+import org.terst.listen.data.FakeEpisodeDao
+import org.terst.listen.data.FakeSubscriptionDao
+import org.terst.listen.data.SubscriptionEntity
+import org.terst.listen.data.SubscriptionKind
+import org.terst.listen.server.ServerEpisode
+import java.io.File
+
+/**
+ * Retrying the episodes that are waiting for the skip server's copy, on a feed refresh.
+ *
+ * Bounded and isolated for the same reasons as `SkipRefresher`: a library can accumulate many
+ * waiting episodes, each a real download, and one bad one must not stop the rest.
+ */
+class WaitingDownloadsTest {
+
+ @get:Rule val temp = TemporaryFolder()
+
+ private lateinit var publisher: MockWebServer
+ private lateinit var episodes: FakeEpisodeDao
+ private lateinit var subscriptions: FakeSubscriptionDao
+
+ @Before
+ fun setUp() {
+ publisher = MockWebServer().apply { start() }
+ episodes = FakeEpisodeDao()
+ subscriptions = FakeSubscriptionDao().apply {
+ rows += SubscriptionEntity(id = "s1", feedUrl = "https://feed", title = "Show", addedAtMs = 0)
+ }
+ }
+
+ @After
+ fun tearDown() = publisher.shutdown()
+
+ private fun waiting(id: String, state: DownloadState = DownloadState.WAITING_FOR_SERVER) = EpisodeEntity(
+ id = id,
+ subscriptionId = "s1",
+ guid = id,
+ title = "Episode $id",
+ mediaUrl = publisher.url("/$id.mp3").toString(),
+ mediaType = "audio/mpeg",
+ discoveredAtMs = 0,
+ downloadState = state,
+ )
+
+ private class ScriptedServer(private val answers: MutableMap<String, ServerEpisode?>) : ServerAudio {
+ val recorded = mutableListOf<String>()
+ override suspend fun canAsk(episode: EpisodeEntity) = true
+ override suspend fun resolve(episode: EpisodeEntity) = answers[episode.id]
+ override suspend fun record(episode: EpisodeEntity, info: ServerEpisode) {
+ recorded += episode.id
+ }
+ }
+
+ private fun readyAt(path: String) = ServerEpisode(
+ ready = true, audioUrl = publisher.url(path).toString(),
+ durationMs = null, introMs = 1, outroMs = 2, detected = true,
+ )
+
+ private fun realDownloader(audio: ServerAudio) =
+ EpisodeDownloader(OkHttpClient(), episodes, temp.newFolder("lib"), null, audio) { 5L }
+
+ private fun retrier(downloader: EpisodeDownloader) =
+ WaitingDownloads(episodes, subscriptions) { episode, kind -> downloader.download(episode, kind) }
+
+ @Test
+ fun `a waiting episode succeeds on retry once the server has it`() = runTest {
+ episodes.rows += waiting("e1")
+ publisher.enqueue(MockResponse().setBody("server-bytes"))
+ val audio = ScriptedServer(mutableMapOf("e1" to readyAt("/audio/e1")))
+
+ val done = retrier(realDownloader(audio)).retryAll()
+
+ assertEquals(1, done)
+ val stored = episodes.rows.single()
+ assertEquals(DownloadState.DOWNLOADED, stored.downloadState)
+ assertTrue("the retry fetched the server's copy", stored.audioFromServer)
+ assertEquals(listOf("e1"), audio.recorded)
+ assertEquals("/audio/e1", publisher.takeRequest().path)
+ }
+
+ @Test
+ fun `a still-pending episode stays waiting, is not failed, and the publisher is not contacted`() = runTest {
+ episodes.rows += waiting("e1")
+ val audio = ScriptedServer(mutableMapOf("e1" to null))
+
+ val done = retrier(realDownloader(audio)).retryAll()
+
+ assertEquals(0, done)
+ assertEquals(DownloadState.WAITING_FOR_SERVER, episodes.rows.single().downloadState)
+ assertEquals(0, publisher.requestCount)
+ }
+
+ @Test
+ fun `only waiting episodes are retried`() = runTest {
+ episodes.rows += waiting("w")
+ episodes.rows += waiting("none", DownloadState.NONE)
+ episodes.rows += waiting("failed", DownloadState.FAILED)
+ episodes.rows += waiting("done", DownloadState.DOWNLOADED)
+ val attempted = mutableListOf<String>()
+
+ WaitingDownloads(episodes, subscriptions) { e, _ -> attempted += e.id; Result.success(File("x")) }.retryAll()
+
+ assertEquals(listOf("w"), attempted)
+ }
+
+ @Test
+ fun `retries each episode with its show's kind`() = runTest {
+ episodes.rows += waiting("w")
+ var kind: SubscriptionKind? = null
+
+ WaitingDownloads(episodes, subscriptions) { _, k -> kind = k; Result.success(File("x")) }.retryAll()
+
+ assertEquals(SubscriptionKind.PODCAST, kind)
+ }
+
+ @Test
+ fun `an episode whose show is gone is skipped`() = runTest {
+ episodes.rows += waiting("w").copy(subscriptionId = "gone")
+ val attempted = mutableListOf<String>()
+
+ WaitingDownloads(episodes, subscriptions) { e, _ -> attempted += e.id; Result.success(File("x")) }.retryAll()
+
+ assertTrue(attempted.isEmpty())
+ }
+
+ @Test
+ fun `one episode's failure does not stop the others`() = runTest {
+ listOf("a", "bad", "c", "boom", "e").forEach { episodes.rows += waiting(it) }
+ val done = mutableListOf<String>()
+
+ val count = WaitingDownloads(episodes, subscriptions) { e, _ ->
+ when (e.id) {
+ "bad" -> Result.failure(java.io.IOException("offline"))
+ "boom" -> error("unexpected")
+ else -> { done += e.id; Result.success(File("x")) }
+ }
+ }.retryAll()
+
+ assertEquals(setOf("a", "c", "e"), done.toSet())
+ assertEquals(3, count)
+ }
+
+ @Test
+ fun `cancellation is not swallowed`() = runTest {
+ episodes.rows += waiting("a")
+ try {
+ WaitingDownloads(episodes, subscriptions) { _, _ -> throw CancellationException("stop") }.retryAll()
+ fail("a cancelled retry must propagate the cancellation")
+ } catch (_: CancellationException) {
+ // expected
+ }
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @Test
+ fun `a retry is bounded in both batch size and concurrency`() = runTest {
+ (1..30).forEach { episodes.rows += waiting("e$it") }
+ val gate = CompletableDeferred<Unit>()
+ val started = mutableListOf<String>()
+
+ val job = launch {
+ WaitingDownloads(episodes, subscriptions) { e, _ ->
+ started += e.id
+ gate.await()
+ Result.success(File("x"))
+ }.retryAll()
+ }
+ runCurrent()
+ assertEquals("only a couple of downloads at once", WaitingDownloads.CONCURRENT, started.size)
+
+ gate.complete(Unit)
+ job.join()
+ assertEquals("a single refresh retries a bounded batch", WaitingDownloads.MAX_PER_REFRESH, started.size)
+ assertFalse(started.isEmpty())
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/download/WaitingForServerTextTest.kt b/app/src/test/java/org/terst/listen/download/WaitingForServerTextTest.kt
new file mode 100644
index 0000000..ed06106
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/download/WaitingForServerTextTest.kt
@@ -0,0 +1,35 @@
+package org.terst.listen.download
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.terst.listen.data.DownloadState
+import org.terst.listen.data.EpisodeEntity
+
+class WaitingForServerTextTest {
+
+ private fun waitingEpisode() = EpisodeEntity(
+ id = "e1", subscriptionId = "s1", guid = "g", title = "One", mediaUrl = "https://cdn/1.mp3",
+ mediaType = "audio/mpeg", discoveredAtMs = 0, downloadState = DownloadState.WAITING_FOR_SERVER,
+ )
+
+ @Test
+ fun theWaitingWordingPromisesARetry() {
+ assertEquals("Waiting for the server's copy; will retry on the next refresh", WaitingForServerText.STATUS)
+ assertEquals("waiting for server", WaitingForServerText.ROW_LABEL)
+ }
+
+ @Test
+ fun thePublisherChoiceStatesTheTradeAndIsOfferedOnlyWhileWaiting() {
+ val offer = WaitingForServerText.publisherOffer(waitingEpisode())!!
+
+ assertEquals("Download from publisher instead", offer.label)
+ assertTrue(offer.warning, offer.warning.contains("can differ"))
+ assertTrue(offer.warning, offer.warning.contains("will not apply"))
+
+ DownloadState.entries.filter { it != DownloadState.WAITING_FOR_SERVER }.forEach {
+ assertNull("no offer in $it", WaitingForServerText.publisherOffer(waitingEpisode().copy(downloadState = it)))
+ }
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/fingerprint/SkipSummaryTest.kt b/app/src/test/java/org/terst/listen/fingerprint/SkipSummaryTest.kt
index 38541a7..d550c69 100644
--- a/app/src/test/java/org/terst/listen/fingerprint/SkipSummaryTest.kt
+++ b/app/src/test/java/org/terst/listen/fingerprint/SkipSummaryTest.kt
@@ -1,7 +1,9 @@
package org.terst.listen.fingerprint
+import org.junit.Assert.assertTrue
import org.junit.Assert.assertEquals
import org.junit.Test
+import org.terst.listen.download.WaitingForServerText
import org.terst.listen.data.DownloadState
import org.terst.listen.data.EpisodeEntity
import org.terst.listen.data.EpisodeMidrollEntity
@@ -469,4 +471,18 @@ class SkipSummaryTest {
assertEquals("intro", got[0].kind)
assertEquals("ad", got[1].kind)
}
+
+ // ---- waiting for the server's copy ---------------------------------------------------
+
+ @Test
+ fun aWaitingEpisodeSaysItIsWaitingRatherThanNotCheckedYet() {
+ val waiting = episodeRow().copy(downloadState = DownloadState.WAITING_FOR_SERVER)
+
+ val skips = SkipSummary.episode(waiting, showRow(), ranges = emptyList())
+
+ assertEquals(
+ listOf(SkipSummary.BreakLine("Breaks", WaitingForServerText.STATUS)),
+ skips.breaks,
+ )
+ }
}
diff --git a/app/src/test/java/org/terst/listen/server/ServerSkipsTest.kt b/app/src/test/java/org/terst/listen/server/ServerSkipsTest.kt
index ae933dc..5d069df 100644
--- a/app/src/test/java/org/terst/listen/server/ServerSkipsTest.kt
+++ b/app/src/test/java/org/terst/listen/server/ServerSkipsTest.kt
@@ -252,4 +252,26 @@ class ServerSkipsTest {
assertEquals("intro", ranges[0].kind)
assertEquals("cue", ranges[1].kind)
}
+
+ @Test
+ fun `can ask when a server is configured and the show is known`() = runTest {
+ assertTrue(subject(StubTransport(null)).canAsk(episode))
+ }
+
+ @Test
+ fun `cannot ask when the show's feed is unknown`() = runTest {
+ assertEquals(false, subject(StubTransport(null), subscriptions = FakeSubscriptionDao()).canAsk(episode))
+ }
+
+ @Test
+ fun `cannot ask when no server is configured`() = runTest {
+ val unconfigured = ServerSkips(
+ SkipServer({ null }, StubTransport(null)),
+ FakeSubscriptionDao().apply { rows += show },
+ FakeShowFingerprintDao(),
+ FakePlaybackStateDao(FakeEpisodeDao().rows),
+ FakeMidrollDao(),
+ )
+ assertEquals(false, unconfigured.canAsk(episode))
+ }
}
diff --git a/app/src/test/java/org/terst/listen/server/SkipRefresherTest.kt b/app/src/test/java/org/terst/listen/server/SkipRefresherTest.kt
index 1858120..ebeec3f 100644
--- a/app/src/test/java/org/terst/listen/server/SkipRefresherTest.kt
+++ b/app/src/test/java/org/terst/listen/server/SkipRefresherTest.kt
@@ -65,6 +65,8 @@ class SkipRefresherTest {
val resolved = mutableListOf<String>()
val recorded = mutableListOf<Pair<String, ServerEpisode>>()
+ override suspend fun canAsk(episode: EpisodeEntity) = true
+
override suspend fun resolve(episode: EpisodeEntity): ServerEpisode? {
resolved += episode.id
check(episode.id !in failing) { "server unreachable: ${episode.id}" }
@@ -168,6 +170,8 @@ class SkipRefresherTest {
val stuck = CompletableDeferred<ServerEpisode?>()
val started = mutableListOf<String>()
val audio = object : ServerAudio {
+ override suspend fun canAsk(episode: EpisodeEntity) = true
+
override suspend fun resolve(episode: EpisodeEntity): ServerEpisode? {
started += episode.id
return stuck.await()
@@ -203,6 +207,8 @@ class SkipRefresherTest {
// propagating it.
val dao = FakeEpisodeDao().apply { rows += episode("e1") }
val audio = object : ServerAudio {
+ override suspend fun canAsk(episode: EpisodeEntity) = true
+
override suspend fun resolve(episode: EpisodeEntity): ServerEpisode? {
throw CancellationException("cancelled mid-query")
}
diff --git a/app/src/test/java/org/terst/listen/server/SkipServerTest.kt b/app/src/test/java/org/terst/listen/server/SkipServerTest.kt
index 4c48600..32d13e0 100644
--- a/app/src/test/java/org/terst/listen/server/SkipServerTest.kt
+++ b/app/src/test/java/org/terst/listen/server/SkipServerTest.kt
@@ -302,4 +302,13 @@ class SkipServerTest {
assertEquals("ad", got?.breaks?.single()?.kind)
}
+
+ @Test
+ fun `knows whether a server is configured`() = runTest {
+ val transport = FakeTransport()
+ assertTrue(server(transport).isConfigured())
+ assertEquals(false, server(transport, baseUrl = null).isConfigured())
+ assertEquals(false, server(transport, token = null).isConfigured())
+ assertTrue("asking must not touch the network", transport.gets.isEmpty())
+ }
}
diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml
index cbb4390..8e94ed5 100644
--- a/config/detekt/detekt.yml
+++ b/config/detekt/detekt.yml
@@ -20,14 +20,17 @@ style:
complexity:
TooManyFunctions:
- thresholdInInterfaces: 21 # Room DAOs are broad by nature -- each query is a method, and
+ thresholdInInterfaces: 22 # Room DAOs are broad by nature -- each query is a method, and
# splitting one table's access across several interfaces to satisfy a count would make the
# data layer harder to follow, not easier. Raised from 20 when EpisodeDao picked up
# downloadedFromServer (the skip-refresh filter): one more genuinely distinct query, not
- # scope creep.
- thresholdInClasses: 20 # same for the view model: it is a flat list of one-line user actions
+ # scope creep. Raised again to 22 for waitingForServer, which cannot reuse queuedDownloads:
+ # a bounded retry must go newest first, or episodes the server has aged out of its newest-8
+ # window would starve the ones it still has.
+ thresholdInClasses: 21 # same for the view model: it is a flat list of one-line user actions
# (enqueue, download, mark played), and splitting it by count rather than by meaning would
- # scatter related behaviour across types for no gain.
+ # scatter related behaviour across types for no gain. 21 with downloadFromPublisher, the
+ # explicit choice the episode screen offers a waiting download.
naming:
FunctionNaming: