From eb1c69d47aaee463960909f99973da4cf791d801 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 09:03:48 +0000 Subject: Subscription resolution, OPML, queue ordering, retention Discovery was cut, so the share sheet and OPML are the only ways to subscribe. That raises the bar on the resolver: anything that fails to resolve is a show the user cannot add at all. It handles a direct feed, a show page via its link rel=alternate, an Apple Podcasts link via its numeric id, and a YouTube channel or playlist via the RSS endpoint YouTube already publishes -- which means a channel becomes an ordinary subscription and only the download step is special. Handle (@name) URLs carry no channel id and return null rather than being guessed at. OPML is ported from FeeDreader but onto XmlPullParserFactory, so unlike the original it is actually testable off-device. Folder nesting is read and recorded even though Listen has no folders: silently discarding structure the user organised is worse than ignoring it. Queue ordering is pure list operations. Re-adding a queued episode moves it rather than duplicating it, play-next on the episode already playing is a no-op instead of interrupting it, and an out-of-range drag index is ignored rather than thrown. Retention keeps an episode when its position is non-zero even though it is marked played -- that combination means it was resumed, and the audio is in use. Disabled is a flag, not an enormous grace period, because playedAt + Long.MAX_VALUE overflows negative and would delete everything. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B --- .../org/terst/listen/feed/SubscriptionResolver.kt | 90 ++++++++++++ app/src/main/java/org/terst/listen/opml/Opml.kt | 102 ++++++++++++++ .../java/org/terst/listen/queue/QueueOrdering.kt | 50 +++++++ .../java/org/terst/listen/queue/RetentionPolicy.kt | 46 ++++++ .../terst/listen/feed/SubscriptionResolverTest.kt | 155 +++++++++++++++++++++ .../test/java/org/terst/listen/opml/OpmlTest.kt | 140 +++++++++++++++++++ .../org/terst/listen/queue/QueueOrderingTest.kt | 92 ++++++++++++ .../org/terst/listen/queue/RetentionPolicyTest.kt | 122 ++++++++++++++++ 8 files changed, 797 insertions(+) create mode 100644 app/src/main/java/org/terst/listen/feed/SubscriptionResolver.kt create mode 100644 app/src/main/java/org/terst/listen/opml/Opml.kt create mode 100644 app/src/main/java/org/terst/listen/queue/QueueOrdering.kt create mode 100644 app/src/main/java/org/terst/listen/queue/RetentionPolicy.kt create mode 100644 app/src/test/java/org/terst/listen/feed/SubscriptionResolverTest.kt create mode 100644 app/src/test/java/org/terst/listen/opml/OpmlTest.kt create mode 100644 app/src/test/java/org/terst/listen/queue/QueueOrderingTest.kt create mode 100644 app/src/test/java/org/terst/listen/queue/RetentionPolicyTest.kt (limited to 'app/src') diff --git a/app/src/main/java/org/terst/listen/feed/SubscriptionResolver.kt b/app/src/main/java/org/terst/listen/feed/SubscriptionResolver.kt new file mode 100644 index 0000000..6e96eda --- /dev/null +++ b/app/src/main/java/org/terst/listen/feed/SubscriptionResolver.kt @@ -0,0 +1,90 @@ +package org.terst.listen.feed + +import java.net.URI + +/** + * Turns whatever the user shared into a subscribable feed URL. + * + * Discovery was cut from scope, so this and OPML import are the only ways in. That raises the + * bar: a share that fails to resolve is a subscription the user cannot add at all. + * + * The pure parts live here so they are testable without a network. The fetch-and-inspect steps + * (HTML discovery, the iTunes lookup) compose these with a FeedFetcher. + */ +object SubscriptionResolver { + + private val URL_REGEX = Regex("""https?://[^\s<>"']+""") + private val LINK_TAG = Regex("""]*>""", RegexOption.IGNORE_CASE) + private val APPLE_ID = Regex("""/id(\d+)""") + private val YOUTUBE_CHANNEL = Regex("""/channel/(UC[\w-]+)""") + private val YOUTUBE_PLAYLIST = Regex("""[?&]list=([\w-]+)""") + + private val FEED_TYPES = setOf( + "application/rss+xml", + "application/atom+xml", + "application/feed+json", + "text/xml", + ) + + /** Share sheets hand over a page title and a URL together as often as a bare URL. */ + fun extractUrl(sharedText: String): String? = + URL_REGEX.find(sharedText)?.value?.trimEnd('.', ',', ')', ']', '!', ';') + + /** + * Apple Podcasts pages are a common share target and are not feeds. The numeric id can be + * exchanged for the real feed URL through the public iTunes lookup endpoint. + */ + fun applePodcastId(url: String): String? { + val host = hostOf(url) ?: return null + if (!host.endsWith("podcasts.apple.com") && !host.endsWith("itunes.apple.com")) return null + return APPLE_ID.find(url)?.groupValues?.get(1) + } + + fun isYouTube(url: String): Boolean { + val host = hostOf(url) ?: return false + return host == "youtu.be" || host.removePrefix("www.").removePrefix("m.") == "youtube.com" + } + + /** + * A YouTube channel publishes an RSS feed, so a channel becomes an ordinary subscription and + * only the download step is special. + * + * Handle (@name) and legacy /c/ URLs carry no channel id and need the page fetched, so they + * return null here rather than guessing. + */ + fun youtubeFeedUrl(url: String): String? { + if (!isYouTube(url)) return null + YOUTUBE_CHANNEL.find(url)?.let { + return "https://www.youtube.com/feeds/videos.xml?channel_id=${it.groupValues[1]}" + } + YOUTUBE_PLAYLIST.find(url)?.let { + return "https://www.youtube.com/feeds/videos.xml?playlist_id=${it.groupValues[1]}" + } + return null + } + + /** + * Finds a feed declared by a show's web page. + * + * Parsed with a regex rather than an XML parser on purpose: this is HTML, which is usually + * not well-formed XML, and only one element is wanted. + */ + fun findFeedLink(html: String, pageUrl: String): String? = + LINK_TAG.findAll(html) + .map { it.value } + .filter { attr(it, "rel")?.lowercase() == "alternate" } + .filter { attr(it, "type")?.lowercase() in FEED_TYPES } + .mapNotNull { attr(it, "href") } + .firstNotNullOfOrNull { absolutise(it, pageUrl) } + + /** Attribute values may be single- or double-quoted, in any order within the tag. */ + private fun attr(tag: String, name: String): String? = + Regex("""\b$name\s*=\s*["']([^"']*)["']""", RegexOption.IGNORE_CASE) + .find(tag)?.groupValues?.get(1)?.trim()?.takeIf { it.isNotEmpty() } + + private fun absolutise(href: String, pageUrl: String): String? = + runCatching { URI(pageUrl).resolve(href).toString() }.getOrNull() + + private fun hostOf(url: String): String? = + runCatching { URI(url).host?.lowercase() }.getOrNull() +} diff --git a/app/src/main/java/org/terst/listen/opml/Opml.kt b/app/src/main/java/org/terst/listen/opml/Opml.kt new file mode 100644 index 0000000..af7e788 --- /dev/null +++ b/app/src/main/java/org/terst/listen/opml/Opml.kt @@ -0,0 +1,102 @@ +package org.terst.listen.opml + +import org.xmlpull.v1.XmlPullParser +import org.xmlpull.v1.XmlPullParserFactory +import java.io.InputStream +import java.io.OutputStream + +data class OpmlFeed( + val title: String, + val xmlUrl: String, + val htmlUrl: String? = null, + val folder: String? = null, +) + +/** + * OPML import and export. + * + * This is the migration path off another client, so import has to tolerate whatever that client + * wrote -- including folder nesting, which Listen has no concept of. Folders are recorded rather + * than used, because silently discarding structure the user organised is worse than ignoring it. + * + * Ported from FeeDreader, but on XmlPullParserFactory rather than android.util.Xml, so it can + * actually be tested off-device. + */ +object Opml { + + private const val OUTLINE = "outline" + + fun parse(input: InputStream): List { + val parser = XmlPullParserFactory.newInstance().newPullParser() + parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false) + parser.setInput(input, null) + + val feeds = mutableListOf() + // One entry per open : a folder contributes its name, anything else null. The + // current folder is the nearest named ancestor. + val openOutlines = mutableListOf() + + while (parser.next() != XmlPullParser.END_DOCUMENT) { + val isOutline = parser.name?.equals(OUTLINE, ignoreCase = true) == true + if (!isOutline) continue + + when (parser.eventType) { + XmlPullParser.START_TAG -> { + val xmlUrl = parser.attr("xmlUrl") + if (xmlUrl != null) { + feeds += OpmlFeed( + title = parser.attr("title") ?: parser.attr("text") ?: xmlUrl, + xmlUrl = xmlUrl, + htmlUrl = parser.attr("htmlUrl"), + folder = openOutlines.filterNotNull().lastOrNull(), + ) + openOutlines += null + } else { + openOutlines += parser.attr("text") ?: parser.attr("title") + } + } + + XmlPullParser.END_TAG -> if (openOutlines.isNotEmpty()) { + openOutlines.removeAt(openOutlines.lastIndex) + } + } + } + return feeds + } + + fun write(feeds: List, output: OutputStream) { + // The serializer escapes attribute values, which is what keeps an ampersand in a title + // or a query string from producing a document nothing can read back. + val out = XmlPullParserFactory.newInstance().newSerializer() + out.setOutput(output, "UTF-8") + out.startDocument("UTF-8", true) + + out.startTag(null, "opml") + out.attribute(null, "version", "2.0") + + out.startTag(null, "head") + out.startTag(null, "title") + out.text("Listen subscriptions") + out.endTag(null, "title") + out.endTag(null, "head") + + out.startTag(null, "body") + for (feed in feeds) { + out.startTag(null, OUTLINE) + out.attribute(null, "text", feed.title) + out.attribute(null, "title", feed.title) + out.attribute(null, "type", "rss") + out.attribute(null, "xmlUrl", feed.xmlUrl) + feed.htmlUrl?.let { out.attribute(null, "htmlUrl", it) } + out.endTag(null, OUTLINE) + } + out.endTag(null, "body") + + out.endTag(null, "opml") + out.endDocument() + out.flush() + } + + private fun XmlPullParser.attr(name: String): String? = + getAttributeValue(null, name)?.trim()?.takeIf { it.isNotEmpty() } +} diff --git a/app/src/main/java/org/terst/listen/queue/QueueOrdering.kt b/app/src/main/java/org/terst/listen/queue/QueueOrdering.kt new file mode 100644 index 0000000..4cc6543 --- /dev/null +++ b/app/src/main/java/org/terst/listen/queue/QueueOrdering.kt @@ -0,0 +1,50 @@ +package org.terst.listen.queue + +/** + * Ordering for the single Up Next queue. + * + * Pure list operations rather than DAO calls, so the behaviour that actually matters -- what + * happens when you re-add something already queued, or drag past the end -- is testable directly. + * The head of the list is the episode currently playing. + */ +object QueueOrdering { + + /** Adds to the end, or moves an already-queued episode there. Never duplicates. */ + fun append(queue: List, episodeId: String): List = + queue.filterNot { it == episodeId } + episodeId + + /** + * Puts an episode immediately after the one playing. + * + * Asking to play next what is already playing is a no-op: moving it behind itself would + * interrupt playback, which is never what the gesture means. + */ + fun playNext(queue: List, episodeId: String): List { + if (queue.firstOrNull() == episodeId) return queue + val rest = queue.filterNot { it == episodeId } + if (rest.isEmpty()) return listOf(episodeId) + return listOf(rest.first(), episodeId) + rest.drop(1) + } + + fun remove(queue: List, episodeId: String): List = + queue.filterNot { it == episodeId } + + /** Out-of-range indices are ignored rather than throwing: a drag can report one mid-gesture. */ + fun move(queue: List, from: Int, to: Int): List { + if (from !in queue.indices || to !in queue.indices || from == to) return queue + val mutable = queue.toMutableList() + mutable.add(to, mutable.removeAt(from)) + return mutable + } + + /** + * Drops the finished episode and reports what plays next. + * + * Returns the new head rather than the removed episode, because the caller's next question is + * always "what now?". + */ + fun advance(queue: List): Pair> { + val remaining = queue.drop(1) + return remaining.firstOrNull() to remaining + } +} diff --git a/app/src/main/java/org/terst/listen/queue/RetentionPolicy.kt b/app/src/main/java/org/terst/listen/queue/RetentionPolicy.kt new file mode 100644 index 0000000..cd05d32 --- /dev/null +++ b/app/src/main/java/org/terst/listen/queue/RetentionPolicy.kt @@ -0,0 +1,46 @@ +package org.terst.listen.queue + +import org.terst.listen.data.DownloadState +import org.terst.listen.data.EpisodeEntity + +/** + * Decides when a finished episode's audio can be reclaimed. + * + * Auto-delete once played, with a grace period. The grace period is the substance of it: an + * episode marked played by a mis-tap, or by an outro skip that fired early, has to still be + * there when the listener reaches back for it. + */ +class RetentionPolicy( + private val gracePeriodMs: Long, + private val enabled: Boolean = true, +) { + + fun shouldDelete(episode: EpisodeEntity, nowMs: Long): Boolean { + if (!enabled) return false + val playedAt = episode.playedAtMs ?: return false + + return ( + // Only a downloaded file is worth reclaiming; deleting anything else discards the + // played record for no disk saved. + episode.downloadState == DownloadState.DOWNLOADED && + episode.localPath != null && + // A non-zero position means it was resumed after being marked played, so the + // mark is stale and the audio is in use. + episode.positionMs == 0L && + nowMs > playedAt + gracePeriodMs + ) + } + + fun selectDeletable(episodes: List, nowMs: Long): List = + episodes.filter { shouldDelete(it, nowMs) } + + companion object { + /** + * Keeps everything, for a series worth re-listening to. + * + * A flag rather than an enormous grace period: `playedAt + Long.MAX_VALUE` overflows to + * a negative number and would delete everything immediately. + */ + val Disabled = RetentionPolicy(gracePeriodMs = 0, enabled = false) + } +} diff --git a/app/src/test/java/org/terst/listen/feed/SubscriptionResolverTest.kt b/app/src/test/java/org/terst/listen/feed/SubscriptionResolverTest.kt new file mode 100644 index 0000000..c219dcf --- /dev/null +++ b/app/src/test/java/org/terst/listen/feed/SubscriptionResolverTest.kt @@ -0,0 +1,155 @@ +package org.terst.listen.feed + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * With discovery cut from scope, the share sheet is how subscriptions get added. Whatever the + * user shares -- a feed, a show's web page, an Apple Podcasts link, a YouTube channel -- has to + * come out as something subscribable. + * + * These cover the parts that need no network. The fetch-and-inspect path is in + * SubscriptionResolver itself. + */ +class SubscriptionResolverTest { + + @Test + fun `extracts a url from shared text`() { + val text = "Listen to this https://example.com/feed.xml it's great" + assertEquals("https://example.com/feed.xml", SubscriptionResolver.extractUrl(text)) + } + + @Test + fun `extracts a bare url`() { + assertEquals("https://example.com/f", SubscriptionResolver.extractUrl("https://example.com/f")) + } + + /** Android share sheets routinely wrap the URL in the page title. */ + @Test + fun `extracts a url from a title-plus-link share`() { + val text = "The Rest Is History\nhttps://podcasts.apple.com/gb/podcast/id1537788786" + assertEquals("https://podcasts.apple.com/gb/podcast/id1537788786", SubscriptionResolver.extractUrl(text)) + } + + @Test + fun `returns null when there is no url`() { + assertNull(SubscriptionResolver.extractUrl("just some words")) + assertNull(SubscriptionResolver.extractUrl("")) + } + + @Test + fun `strips trailing punctuation from a shared url`() { + assertEquals("https://example.com/f", SubscriptionResolver.extractUrl("see https://example.com/f.")) + } + + // --- Apple Podcasts ------------------------------------------------------------------- + + @Test + fun `recognises an apple podcasts url and extracts its id`() { + val cases = listOf( + "https://podcasts.apple.com/gb/podcast/the-rest-is-history/id1537788786", + "https://podcasts.apple.com/us/podcast/id1537788786?i=1000600000000", + "https://itunes.apple.com/us/podcast/something/id1537788786", + ) + for (url in cases) { + assertEquals("failed for $url", "1537788786", SubscriptionResolver.applePodcastId(url)) + } + } + + @Test + fun `non-apple urls have no apple id`() { + assertNull(SubscriptionResolver.applePodcastId("https://example.com/podcast/id123")) + assertNull(SubscriptionResolver.applePodcastId("https://podcasts.apple.com/gb/podcast/no-id-here")) + } + + // --- YouTube -------------------------------------------------------------------------- + + /** + * A YouTube channel has an RSS feed, which means a channel can be an ordinary subscription + * and only the download step is special. + */ + @Test + fun `builds a channel feed url from a channel id`() { + val resolved = SubscriptionResolver.youtubeFeedUrl("https://www.youtube.com/channel/UCabc123") + assertEquals("https://www.youtube.com/feeds/videos.xml?channel_id=UCabc123", resolved) + } + + @Test + fun `recognises a playlist url`() { + val resolved = SubscriptionResolver.youtubeFeedUrl("https://www.youtube.com/playlist?list=PL12345") + assertEquals("https://www.youtube.com/feeds/videos.xml?playlist_id=PL12345", resolved) + } + + /** Handle and /c/ URLs need the page fetched to find the channel id, so not here. */ + @Test + fun `a handle url cannot be resolved without a fetch`() { + assertNull(SubscriptionResolver.youtubeFeedUrl("https://www.youtube.com/@SomeHandle")) + } + + @Test + fun `a plain video url is not a channel feed`() { + assertNull(SubscriptionResolver.youtubeFeedUrl("https://youtu.be/Bo9Z29YjzVk")) + } + + @Test + fun `non-youtube urls are not channel feeds`() { + assertNull(SubscriptionResolver.youtubeFeedUrl("https://example.com/channel/UCabc")) + } + + @Test + fun `identifies youtube urls`() { + assertTrue(SubscriptionResolver.isYouTube("https://www.youtube.com/watch?v=abc")) + assertTrue(SubscriptionResolver.isYouTube("https://youtu.be/abc")) + assertTrue(SubscriptionResolver.isYouTube("https://m.youtube.com/watch?v=abc")) + assertTrue(!SubscriptionResolver.isYouTube("https://example.com/youtube")) + } + + // --- HTML feed discovery -------------------------------------------------------------- + + @Test + fun `finds a feed link in html`() { + val html = """ + + + x + """.trimIndent() + assertEquals( + "https://example.com/feed.xml", + SubscriptionResolver.findFeedLink(html, "https://example.com/show") + ) + } + + @Test + fun `resolves a relative feed link against the page url`() { + val html = """""" + assertEquals( + "https://example.com/feed.xml", + SubscriptionResolver.findFeedLink(html, "https://example.com/shows/one") + ) + } + + @Test + fun `accepts atom and single-quoted attributes`() { + val html = """""" + assertEquals("https://e.com/atom", SubscriptionResolver.findFeedLink(html, "https://e.com")) + } + + @Test + fun `handles attributes in any order`() { + val html = """""" + assertEquals("https://e.com/f.xml", SubscriptionResolver.findFeedLink(html, "https://e.com")) + } + + @Test + fun `ignores non-feed alternate links`() { + val html = """""" + assertNull(SubscriptionResolver.findFeedLink(html, "https://e.com")) + } + + @Test + fun `returns null when there is no feed link`() { + assertNull(SubscriptionResolver.findFeedLink("nothing", "https://e.com")) + } +} diff --git a/app/src/test/java/org/terst/listen/opml/OpmlTest.kt b/app/src/test/java/org/terst/listen/opml/OpmlTest.kt new file mode 100644 index 0000000..66edc5d --- /dev/null +++ b/app/src/test/java/org/terst/listen/opml/OpmlTest.kt @@ -0,0 +1,140 @@ +package org.terst.listen.opml + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.ByteArrayOutputStream + +/** + * OPML is the Pocket Casts migration path, so import has to cope with whatever another client + * wrote. It carries subscriptions only -- positions, history and queue do not travel. + */ +class OpmlTest { + + private fun parse(xml: String) = Opml.parse(xml.trimIndent().byteInputStream()) + + @Test + fun `reads a flat subscription list`() { + val feeds = parse( + """ + + Pocket Casts Feeds + + + + """ + ) + assertEquals(2, feeds.size) + assertEquals("A Show", feeds[0].title) + assertEquals("https://a.com/feed", feeds[0].xmlUrl) + assertEquals("https://a.com", feeds[0].htmlUrl) + assertNull(feeds[1].htmlUrl) + } + + @Test + fun `prefers title over text when both are present`() { + val feeds = parse("""""") + assertEquals("T", feeds.single().title) + } + + @Test + fun `falls back to the url when a feed has no name`() { + val feeds = parse("""""") + assertEquals("https://a.com/feed", feeds.single().title) + } + + /** Other clients nest subscriptions in folders. Listen has no folders, but must not lose feeds. */ + @Test + fun `reads feeds nested inside folders`() { + val feeds = parse( + """ + + + + + + + + """ + ) + assertEquals(3, feeds.size) + assertEquals(listOf("A Show", "B Show", "Loose"), feeds.map { it.title }) + } + + @Test + fun `records the folder a feed came from`() { + val feeds = parse( + """ + """ + ) + assertEquals("History", feeds.single().folder) + } + + @Test + fun `an outline without a url is not a feed`() { + val feeds = parse("""""") + assertTrue(feeds.isEmpty()) + } + + @Test + fun `an empty document yields nothing`() { + assertTrue(parse("""""").isEmpty()) + } + + @Test + fun `unescapes entities in titles`() { + val feeds = parse("""""") + assertEquals("Rock & Roll", feeds.single().title) + } + + @Test + fun `ignores a url-less outline that is not a folder`() { + val feeds = parse("""""") + assertTrue(feeds.isEmpty()) + } + + // --- export --------------------------------------------------------------------------- + + private fun write(feeds: List): String { + val out = ByteArrayOutputStream() + Opml.write(feeds, out) + return out.toString("UTF-8") + } + + @Test + fun `writes a document another client can read`() { + val xml = write(listOf(OpmlFeed("A Show", "https://a.com/feed", "https://a.com"))) + assertTrue(xml, xml.contains("""xmlUrl="https://a.com/feed"""")) + assertTrue(xml, xml.contains("(), remaining) + } + + @Test + fun `advancing the last episode empties the queue`() { + val (next, remaining) = QueueOrdering.advance(listOf("a")) + assertEquals(null, next) + assertEquals(emptyList(), remaining) + } +} diff --git a/app/src/test/java/org/terst/listen/queue/RetentionPolicyTest.kt b/app/src/test/java/org/terst/listen/queue/RetentionPolicyTest.kt new file mode 100644 index 0000000..280ddb3 --- /dev/null +++ b/app/src/test/java/org/terst/listen/queue/RetentionPolicyTest.kt @@ -0,0 +1,122 @@ +package org.terst.listen.queue + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.terst.listen.data.DownloadState +import org.terst.listen.data.EpisodeEntity + +/** + * Auto-delete once played, with a grace period. + * + * The grace period is the whole point: an episode marked played by a mis-tap, or by a skip-outro + * that fired early, must still be there when the listener goes back for it a minute later. + */ +class RetentionPolicyTest { + + private val hour = 3_600_000L + private val day = 24 * hour + private val policy = RetentionPolicy(gracePeriodMs = day) + + private fun episode( + id: String = "e1", + playedAtMs: Long? = null, + downloadState: DownloadState = DownloadState.DOWNLOADED, + localPath: String? = "/data/e1.mp3", + positionMs: Long = 0, + ) = EpisodeEntity( + id = id, + subscriptionId = "s1", + guid = id, + title = "Episode", + mediaUrl = "https://cdn/e.mp3", + mediaType = "audio/mpeg", + discoveredAtMs = 0, + playedAtMs = playedAtMs, + downloadState = downloadState, + localPath = localPath, + positionMs = positionMs, + ) + + @Test + fun `an unplayed download is kept`() { + assertFalse(policy.shouldDelete(episode(playedAtMs = null), nowMs = 100 * day)) + } + + @Test + fun `a played download past its grace period is deleted`() { + assertTrue(policy.shouldDelete(episode(playedAtMs = 0), nowMs = day + 1)) + } + + /** The mis-tap case. */ + @Test + fun `a just-played download is kept during the grace period`() { + assertFalse(policy.shouldDelete(episode(playedAtMs = 10 * day), nowMs = 10 * day + hour)) + } + + @Test + fun `the boundary keeps the episode`() { + val playedAt = 10 * day + assertFalse("exactly at the boundary is still within grace", + policy.shouldDelete(episode(playedAtMs = playedAt), nowMs = playedAt + day)) + assertTrue(policy.shouldDelete(episode(playedAtMs = playedAt), nowMs = playedAt + day + 1)) + } + + /** Nothing on disk means nothing to reclaim; deleting would only lose the played record. */ + @Test + fun `an episode that was never downloaded is not deleted`() { + assertFalse( + policy.shouldDelete( + episode(playedAtMs = 0, downloadState = DownloadState.NONE, localPath = null), + nowMs = 100 * day, + ) + ) + } + + @Test + fun `an in-flight download is never deleted`() { + for (state in listOf(DownloadState.QUEUED, DownloadState.DOWNLOADING)) { + assertFalse( + "state $state should not be deleted", + policy.shouldDelete(episode(playedAtMs = 0, downloadState = state), nowMs = 100 * day), + ) + } + } + + /** + * A resumed episode is back in progress. Its played mark is stale and deleting the file + * would take the audio out from under the listener. + */ + @Test + fun `a played episode that has been resumed is kept`() { + assertFalse( + policy.shouldDelete(episode(playedAtMs = 0, positionMs = 500_000), nowMs = 100 * day) + ) + } + + @Test + fun `selects only the deletable episodes from a batch`() { + val candidates = listOf( + episode(id = "old-played", playedAtMs = 0), + episode(id = "recent-played", playedAtMs = 100 * day), + episode(id = "unplayed", playedAtMs = null), + episode(id = "resumed", playedAtMs = 0, positionMs = 90_000), + ) + val deletable = policy.selectDeletable(candidates, nowMs = 100 * day + hour).map { it.id } + assertEquals(listOf("old-played"), deletable) + } + + @Test + fun `a zero grace period deletes as soon as an episode is played`() { + val immediate = RetentionPolicy(gracePeriodMs = 0) + assertTrue(immediate.shouldDelete(episode(playedAtMs = 10), nowMs = 11)) + } + + /** Keeping everything has to be expressible, for a series worth re-listening to. */ + @Test + fun `retention can be disabled entirely`() { + assertFalse(RetentionPolicy.Disabled.shouldDelete(episode(playedAtMs = 0), nowMs = 1000 * day)) + assertTrue(RetentionPolicy.Disabled.selectDeletable(listOf(episode(playedAtMs = 0)), 1000 * day).isEmpty()) + } +} -- cgit v1.2.3