summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-09-21 18:24:16 +0000
committerClaude <noreply@anthropic.com>2026-09-21 18:24:16 +0000
commitcabe26374ef047c93bcebed83d70923631910fc0 (patch)
tree4fa05d7a94870ae579c27bda1d2266097ed31150
parent3eb15dbd84da689943f54d48a4d4974aa3d7f017 (diff)
Actually resolve shared links, and guard against dead code
Bug report from the phone: pasting a link produced "Could not parse feed: Unexpected <! (position:START_DOCUMENT...)" -- the XML parser meeting <!DOCTYPE html>. The cause was mine. SubscriptionResolver.findFeedLink and applePodcastId were written, unit-tested, documented as how the share sheet would work, and had zero call sites. The raw URL went straight to the parser. FeedResolver now does what I said the app already did: a direct feed resolves to itself, a show's web page resolves through its link rel=alternate, an Apple Podcasts link goes through the iTunes lookup, and a YouTube channel or playlist uses YouTube's own RSS endpoint. Both entry points -- the in-app add box and the share sheet -- go through it, so they cannot drift apart again. Verified on-device against real sites: thisamericanlife.org resolved to /podcast/rss.xml, a direct megaphone feed passed through unchanged, and a YouTube handle failed with an explanation rather than opaquely. Errors are now written for a person. "Unexpected <!" told the user nothing; "That's a web page, not a podcast feed, and it doesn't link to one" tells them what to do. A test asserts parser internals never reach the UI. Fixing the class, not just the instance: - A reachability test fails when a helper object's function has no production caller. Its first version passed on the very code it was written to catch, because a bare name also matches a KDoc cross-reference -- it now requires a call-shaped match. Verified by reintroducing the original bug and watching it fail. - That audit found a real functional gap, not just unused code: QueueRepository.advance had no caller, so an episode ending never advanced the queue. Playback now marks the episode played, advances, and starts the next one. 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--app/src/main/java/org/terst/listen/data/Repositories.kt3
-rw-r--r--app/src/main/java/org/terst/listen/feed/FeedResolver.kt115
-rw-r--r--app/src/main/java/org/terst/listen/playback/PlayerConnection.kt21
-rw-r--r--app/src/main/java/org/terst/listen/ui/ListenViewModel.kt36
-rw-r--r--app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt22
-rw-r--r--app/src/main/java/org/terst/listen/ui/ShareReceiverActivity.kt40
-rw-r--r--app/src/test/java/org/terst/listen/ReachabilityTest.kt92
-rw-r--r--app/src/test/java/org/terst/listen/feed/FeedResolverTest.kt175
9 files changed, 464 insertions, 42 deletions
diff --git a/.coverage-baseline b/.coverage-baseline
index 65bf252..93f570e 100644
--- a/.coverage-baseline
+++ b/.coverage-baseline
@@ -1 +1 @@
-70.75
+71.77
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 cae5edf..fb869d8 100644
--- a/app/src/main/java/org/terst/listen/data/Repositories.kt
+++ b/app/src/main/java/org/terst/listen/data/Repositories.kt
@@ -5,6 +5,7 @@ import okhttp3.OkHttpClient
import org.terst.listen.download.ChaquopyYouTubeSource
import org.terst.listen.download.EpisodeDownloader
import org.terst.listen.feed.FeedFetcher
+import org.terst.listen.feed.FeedResolver
import org.terst.listen.queue.QueueOrdering
import org.terst.listen.queue.RetentionPolicy
import java.io.File
@@ -44,6 +45,8 @@ class AppGraph(private val context: Context) {
File(context.filesDir, "audio").also { it.mkdirs() }
}
+ val resolver: FeedResolver by lazy { FeedResolver(client) }
+
val repository: SubscriptionRepository by lazy {
SubscriptionRepository(subscriptions, episodes, FeedFetcher(client))
}
diff --git a/app/src/main/java/org/terst/listen/feed/FeedResolver.kt b/app/src/main/java/org/terst/listen/feed/FeedResolver.kt
new file mode 100644
index 0000000..42253d3
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/feed/FeedResolver.kt
@@ -0,0 +1,115 @@
+package org.terst.listen.feed
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import java.io.IOException
+
+/**
+ * Turns whatever the user shared into a feed URL.
+ *
+ * Discovery was cut from scope, so the share sheet and OPML are the only ways to subscribe --
+ * which means anything that fails to resolve is a show the user simply cannot add.
+ *
+ * This exists because [SubscriptionResolver.findFeedLink] was written, tested, and then never
+ * called: the raw shared URL went straight to the XML parser, so sharing a show's web page
+ * produced "Could not parse feed: Unexpected <!" -- the parser meeting `<!DOCTYPE html>`.
+ */
+class FeedResolver(
+ private val client: OkHttpClient,
+ private val lookupBase: String = "https://itunes.apple.com/lookup",
+) {
+
+ sealed interface Resolution {
+ data class Feed(val url: String) : Resolution
+ data class Failed(val message: String) : Resolution
+ }
+
+ suspend fun resolve(sharedText: String): Resolution {
+ val url = SubscriptionResolver.extractUrl(sharedText)
+ ?: sharedText.trim().takeIf { it.startsWith("http", ignoreCase = true) }
+ ?: return Resolution.Failed("No link found in that text.")
+
+ if (SubscriptionResolver.isYouTube(url)) return resolveYouTube(url)
+ SubscriptionResolver.applePodcastId(url)?.let { return resolveApple(it) }
+ return resolveWeb(url)
+ }
+
+ private fun resolveYouTube(url: String): Resolution =
+ SubscriptionResolver.youtubeFeedUrl(url)?.let { Resolution.Feed(it) }
+ // A handle or /c/ URL carries no channel id. Saying so beats failing opaquely, since
+ // the fix is entirely in the user's hands.
+ ?: Resolution.Failed(
+ "That YouTube link doesn't contain a channel id. Open the channel and use the " +
+ "URL with /channel/ in it, or share a playlist."
+ )
+
+ private suspend fun resolveApple(id: String): Resolution = withContext(Dispatchers.IO) {
+ val response = runCatching { fetch("$lookupBase?id=$id&entity=podcast") }
+ .getOrElse { return@withContext Resolution.Failed("Could not reach Apple: ${it.message}") }
+
+ val body = response ?: return@withContext Resolution.Failed("Apple returned nothing for that link.")
+ val feedUrl = runCatching {
+ Json { ignoreUnknownKeys = true }.decodeFromString<LookupResponse>(body)
+ .results.firstNotNullOfOrNull { it.feedUrl }
+ }.getOrNull()
+
+ feedUrl?.let { Resolution.Feed(it) }
+ ?: Resolution.Failed("Apple has no podcast feed for that link.")
+ }
+
+ /**
+ * Fetches the URL and decides what it is.
+ *
+ * Sniffing the body rather than trusting Content-Type: plenty of hosts serve feeds as
+ * text/html or application/octet-stream, and rejecting those would fail on real feeds.
+ */
+ private suspend fun resolveWeb(url: String): Resolution = withContext(Dispatchers.IO) {
+ val body = try {
+ fetch(url) ?: return@withContext Resolution.Failed("That address returned nothing.")
+ } catch (e: IOException) {
+ return@withContext Resolution.Failed(e.message ?: "Could not reach that address.")
+ }
+
+ if (looksLikeFeed(body)) return@withContext Resolution.Feed(url)
+
+ SubscriptionResolver.findFeedLink(body, url)?.let { return@withContext Resolution.Feed(it) }
+
+ Resolution.Failed(
+ "That's a web page, not a podcast feed, and it doesn't link to one. Look for an " +
+ "RSS link on the page and share that instead."
+ )
+ }
+
+ /** Parses as a feed if it yields a title or any episodes; see FeedFetcher for the same rule. */
+ private fun looksLikeFeed(body: String): Boolean {
+ val parsed = runCatching { PodcastFeedParser.parse(body.byteInputStream()) }.getOrNull()
+ ?: return false
+ return parsed.title != null || parsed.episodes.isNotEmpty()
+ }
+
+ /** Returns the body, or throws IOException with a message already fit to show a person. */
+ private fun fetch(url: String): String? {
+ val request = Request.Builder().url(url).header("User-Agent", USER_AGENT).build()
+ client.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) {
+ throw IOException("That address returned HTTP ${response.code}.")
+ }
+ return response.body?.string()
+ }
+ }
+
+ @Serializable
+ private data class LookupResponse(val results: List<LookupResult> = emptyList())
+
+ @Serializable
+ private data class LookupResult(@SerialName("feedUrl") val feedUrl: String? = null)
+
+ private companion object {
+ const val USER_AGENT = "Listen/1.0 (Android podcast client)"
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/playback/PlayerConnection.kt b/app/src/main/java/org/terst/listen/playback/PlayerConnection.kt
index af9cc14..df756ca 100644
--- a/app/src/main/java/org/terst/listen/playback/PlayerConnection.kt
+++ b/app/src/main/java/org/terst/listen/playback/PlayerConnection.kt
@@ -33,6 +33,15 @@ class PlayerConnection(private val context: Context) {
private var controller: MediaController? = null
+ /**
+ * Fired when an episode plays to its end, with that episode's id.
+ *
+ * Exposed as a callback rather than handled here because what happens next -- mark played,
+ * advance the queue, start the next one -- is repository work this class has no business
+ * knowing about.
+ */
+ var onEpisodeFinished: ((String) -> Unit)? = null
+
private val _nowPlaying = MutableStateFlow(
NowPlaying(null, null, isPlaying = false, positionMs = 0, durationMs = null, speed = 1.0f)
)
@@ -61,7 +70,17 @@ class PlayerConnection(private val context: Context) {
private val listener = object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) = publish()
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) = publish()
- override fun onPlaybackStateChanged(playbackState: Int) = publish()
+
+ override fun onPlaybackStateChanged(playbackState: Int) {
+ if (playbackState == Player.STATE_ENDED) {
+ // Read before anything else runs: advancing the queue replaces the media item,
+ // so the id is gone by the time the callback returns.
+ controller?.currentMediaItem?.mediaId?.let { finished ->
+ onEpisodeFinished?.invoke(finished)
+ }
+ }
+ publish()
+ }
}
/**
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 c7a27e5..5d483d6 100644
--- a/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt
+++ b/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt
@@ -14,6 +14,7 @@ import org.terst.listen.data.Graph
import org.terst.listen.data.RefreshOutcome
import org.terst.listen.data.SubscriptionEntity
import org.terst.listen.data.SubscriptionKind
+import org.terst.listen.feed.FeedResolver
import org.terst.listen.feed.SubscriptionResolver
import android.net.Uri
import org.terst.listen.opml.Opml
@@ -108,26 +109,25 @@ class ListenViewModel(app: Application) : AndroidViewModel(app) {
* identically.
*/
fun subscribe(rawInput: String) {
- val url = SubscriptionResolver.extractUrl(rawInput) ?: rawInput.trim()
- if (url.isEmpty()) {
- _banner.value = Banner("Nothing to subscribe to", isError = true)
- return
- }
-
viewModelScope.launch {
- val kind = if (SubscriptionResolver.isYouTube(url)) {
- SubscriptionKind.YOUTUBE_CHANNEL
- } else {
- SubscriptionKind.PODCAST
+ when (val resolved = graph.resolver.resolve(rawInput)) {
+ is FeedResolver.Resolution.Failed ->
+ _banner.value = Banner(resolved.message, isError = true)
+
+ is FeedResolver.Resolution.Feed -> {
+ val kind = if (SubscriptionResolver.isYouTube(resolved.url)) {
+ SubscriptionKind.YOUTUBE_CHANNEL
+ } else {
+ SubscriptionKind.PODCAST
+ }
+ graph.repository.subscribe(resolved.url, kind).fold(
+ onSuccess = { _banner.value = Banner("Subscribed to ${it.title}") },
+ onFailure = {
+ _banner.value = Banner(it.message ?: "Could not subscribe", isError = true)
+ },
+ )
+ }
}
- val feedUrl = SubscriptionResolver.youtubeFeedUrl(url) ?: url
-
- graph.repository.subscribe(feedUrl, kind).fold(
- onSuccess = { _banner.value = Banner("Subscribed to ${it.title}") },
- // The reason, not a generic failure: "couldn't subscribe" gives the user nothing
- // to act on.
- onFailure = { _banner.value = Banner(it.message ?: "Could not subscribe", isError = true) },
- )
}
}
diff --git a/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt b/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt
index 350d3a0..e112837 100644
--- a/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt
+++ b/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt
@@ -32,7 +32,12 @@ class PlayerViewModel(app: Application) : AndroidViewModel(app) {
_banner.value = null
}
- private val connection = PlayerConnection(app).also { it.connect() }
+ private val connection = PlayerConnection(app).also { connection ->
+ connection.connect()
+ // Without this the queue never moves on -- QueueRepository.advance existed with no
+ // caller, which is exactly how a queue stops being a queue.
+ connection.onEpisodeFinished = { episodeId -> onFinished(episodeId) }
+ }
val nowPlaying: StateFlow<NowPlaying> = connection.nowPlaying
init {
@@ -63,6 +68,21 @@ class PlayerViewModel(app: Application) : AndroidViewModel(app) {
}
}
+ /**
+ * An episode reached its end: record it, drop it from the queue, start whatever is next.
+ *
+ * Marking played before advancing matters -- advance rewrites the queue, and doing it the
+ * other way round loses the id.
+ */
+ private fun onFinished(episodeId: String) {
+ viewModelScope.launch {
+ graph.episodes.markPlayed(episodeId, System.currentTimeMillis())
+ val nextId = graph.queue.advance()
+ val next = nextId?.let { graph.episodes.getById(it) } ?: return@launch
+ play(next)
+ }
+ }
+
fun togglePlayPause() = connection.togglePlayPause()
fun skipBack() = connection.seekBy(-SKIP_BACK_MS)
diff --git a/app/src/main/java/org/terst/listen/ui/ShareReceiverActivity.kt b/app/src/main/java/org/terst/listen/ui/ShareReceiverActivity.kt
index 2ee9418..d9d80fe 100644
--- a/app/src/main/java/org/terst/listen/ui/ShareReceiverActivity.kt
+++ b/app/src/main/java/org/terst/listen/ui/ShareReceiverActivity.kt
@@ -8,6 +8,7 @@ import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import org.terst.listen.data.Graph
import org.terst.listen.data.SubscriptionKind
+import org.terst.listen.feed.FeedResolver
import org.terst.listen.feed.SubscriptionResolver
/**
@@ -29,30 +30,27 @@ class ShareReceiverActivity : ComponentActivity() {
?.getStringExtra(Intent.EXTRA_TEXT)
.orEmpty()
- val url = SubscriptionResolver.extractUrl(shared)
- if (url == null) {
- reportAndFinish("No link found in that share")
- return
- }
-
- // Toast only -- finishing here would cancel lifecycleScope and take the subscribe with
- // it, so the activity stays alive until the network call completes.
- toast("Resolving…")
+ toast("Resolving\u2026")
lifecycleScope.launch {
val graph = Graph.get(applicationContext)
- val kind = if (SubscriptionResolver.isYouTube(url)) {
- SubscriptionKind.YOUTUBE_CHANNEL
- } else {
- SubscriptionKind.PODCAST
- }
- val feedUrl = SubscriptionResolver.youtubeFeedUrl(url) ?: url
- graph.repository.subscribe(feedUrl, kind).fold(
- onSuccess = { reportAndFinish("Subscribed to ${it.title}") },
- // The actual reason. "Couldn't subscribe" leaves the user with nothing to act on,
- // and this is the only feedback a share ever gets.
- onFailure = { reportAndFinish(it.message ?: "Could not subscribe") },
- )
+ // The same resolution the in-app add box uses, so a shared show page or Apple
+ // Podcasts link behaves identically in both places.
+ when (val resolved = graph.resolver.resolve(shared)) {
+ is FeedResolver.Resolution.Failed -> reportAndFinish(resolved.message)
+
+ is FeedResolver.Resolution.Feed -> {
+ val kind = if (SubscriptionResolver.isYouTube(resolved.url)) {
+ SubscriptionKind.YOUTUBE_CHANNEL
+ } else {
+ SubscriptionKind.PODCAST
+ }
+ graph.repository.subscribe(resolved.url, kind).fold(
+ onSuccess = { reportAndFinish("Subscribed to ${it.title}") },
+ onFailure = { reportAndFinish(it.message ?: "Could not subscribe") },
+ )
+ }
+ }
}
}
diff --git a/app/src/test/java/org/terst/listen/ReachabilityTest.kt b/app/src/test/java/org/terst/listen/ReachabilityTest.kt
new file mode 100644
index 0000000..4eaed9e
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/ReachabilityTest.kt
@@ -0,0 +1,92 @@
+package org.terst.listen
+
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.io.File
+
+/**
+ * Fails when a function on a helper object has no caller in production code.
+ *
+ * Written after `SubscriptionResolver.findFeedLink` was built, unit-tested, described in the
+ * design notes, and never called -- so sharing a show's web page produced a raw XML parser
+ * exception. Tests passing said nothing, because a tested function with no call site is still
+ * dead code.
+ *
+ * Scoped to `object` declarations on purpose: that is where pure helpers live here, and it is
+ * the shape where unreachable code hides without a compiler warning.
+ */
+class ReachabilityTest {
+
+ /**
+ * Functions that are deliberately not called from production.
+ *
+ * Every entry needs a reason. An allowlist without justification is just a way of turning
+ * the check off one line at a time.
+ */
+ private val allowed = mapOf(
+ "YouTubeAudio.ytDlpVersion" to
+ "Called from YouTubeAudioTest, which fails on a stale bundled yt-dlp. That guard " +
+ "is the whole point; it has no job in production.",
+ "YouTubeAudio.probe" to
+ "Metadata without downloading. Unused while the only YouTube path is download, but " +
+ "the resolver will want it for episode durations.",
+ )
+
+ private val mainDir = File("src/main/java/org/terst/listen")
+
+ private val objectDecl = Regex("""^(?:internal )?object (\w+)""", RegexOption.MULTILINE)
+
+ /** Top-level members of an object are indented exactly four spaces. */
+ private val memberFun = Regex("""^ (?:suspend )?fun (\w+)""", RegexOption.MULTILINE)
+
+ /** Any declaration starting at column zero ends the preceding object's body. */
+ private val nextTopLevel = Regex(
+ """^(?:@|private |internal |abstract |sealed |data )*(?:class|object|interface|fun|val) """,
+ RegexOption.MULTILINE,
+ )
+
+ /** Unreferenced functions declared on one object. */
+ private fun deadIn(
+ declaration: MatchResult,
+ file: File,
+ bodies: Map<File, String>,
+ ): List<String> {
+ val body = bodies.getValue(file)
+ val objectName = declaration.groupValues[1]
+ // Only this object's own body. A file often holds an object and a class, and scanning
+ // the whole file attributes the class's methods to the object.
+ val slice = body.substring(declaration.range.first).let { rest ->
+ val next = nextTopLevel.find(rest, startIndex = 1)?.range?.first ?: rest.length
+ rest.substring(0, next)
+ }
+
+ return memberFun.findAll(slice)
+ .map { "$objectName.${it.groupValues[1]}" }
+ .filterNot { it in allowed }
+ // The open paren matters: a bare name also matches a KDoc cross-reference like
+ // [SubscriptionResolver.findFeedLink], which made an earlier version of this check
+ // pass on the very dead code it was written to catch.
+ .filterNot { qualified -> bodies.any { (other, b) -> other != file && b.contains("$qualified(") } }
+ .toList()
+ }
+
+ @Test
+ fun `every helper object function has a caller`() {
+ val sources = mainDir.walkTopDown().filter { it.extension == "kt" }.toList()
+ assertTrue("could not find sources under ${mainDir.absolutePath}", sources.isNotEmpty())
+
+ val bodies = sources.associateWith { it.readText() }
+
+ val dead = bodies.flatMap { (file, body) ->
+ objectDecl.findAll(body).flatMap { declaration ->
+ deadIn(declaration, file, bodies)
+ }
+ }
+
+ assertTrue(
+ "No production caller for: ${dead.sorted()}. Either wire it up, delete it, or add " +
+ "it to the allowlist in this test with a reason.",
+ dead.isEmpty(),
+ )
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/feed/FeedResolverTest.kt b/app/src/test/java/org/terst/listen/feed/FeedResolverTest.kt
new file mode 100644
index 0000000..55e33b2
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/feed/FeedResolverTest.kt
@@ -0,0 +1,175 @@
+package org.terst.listen.feed
+
+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.Test
+
+/**
+ * Turning whatever was shared into a feed URL.
+ *
+ * Written after a real bug report: pasting a show's web page produced
+ * "Could not parse feed: Unexpected <!" -- the raw URL went straight to the XML parser, which
+ * choked on <!DOCTYPE html>. SubscriptionResolver.findFeedLink existed and was tested but had
+ * zero call sites.
+ */
+class FeedResolverTest {
+
+ private lateinit var server: MockWebServer
+ private lateinit var resolver: FeedResolver
+
+ private val feedXml = """<?xml version="1.0"?>
+ <rss version="2.0"><channel>
+ <title>A Show</title><link>https://example.com</link><description>d</description>
+ <item><title>One</title><guid>g1</guid>
+ <enclosure url="https://cdn/1.mp3" length="10" type="audio/mpeg"/></item>
+ </channel></rss>""".trimIndent()
+
+ @Before
+ fun setUp() {
+ server = MockWebServer()
+ server.start()
+ resolver = FeedResolver(OkHttpClient())
+ }
+
+ @After
+ fun tearDown() = server.shutdown()
+
+ private fun url(path: String) = server.url(path).toString()
+
+ private suspend fun resolve(input: String) = resolver.resolve(input)
+
+ @Test
+ fun `a direct feed url resolves to itself`() = runTest {
+ server.enqueue(MockResponse().setBody(feedXml))
+ val result = resolve(url("/feed.xml"))
+ assertEquals(FeedResolver.Resolution.Feed(url("/feed.xml")), result)
+ }
+
+ /** The reported bug: a show's web page, not a feed. */
+ @Test
+ fun `a show page resolves through its feed link`() = runTest {
+ val html = """
+ <!DOCTYPE html><html><head>
+ <link rel="alternate" type="application/rss+xml" href="${url("/rss")}">
+ </head><body>The Show</body></html>
+ """.trimIndent()
+ server.enqueue(MockResponse().setHeader("Content-Type", "text/html").setBody(html))
+
+ val result = resolve(url("/show"))
+ assertEquals(FeedResolver.Resolution.Feed(url("/rss")), result)
+ }
+
+ @Test
+ fun `a relative feed link resolves against the page`() = runTest {
+ val html = """<!DOCTYPE html><html><head>
+ <link rel="alternate" type="application/rss+xml" href="/rss"></head></html>"""
+ server.enqueue(MockResponse().setHeader("Content-Type", "text/html").setBody(html))
+
+ val result = resolve(url("/shows/one"))
+ assertEquals(FeedResolver.Resolution.Feed(url("/rss")), result)
+ }
+
+ /**
+ * An HTML page with no feed link is the case that produced a raw parser exception. The
+ * message has to say what happened in terms the person can act on.
+ */
+ @Test
+ fun `a page with no feed link fails understandably`() = runTest {
+ server.enqueue(
+ MockResponse().setHeader("Content-Type", "text/html")
+ .setBody("<!DOCTYPE html><html><body>Just a page</body></html>")
+ )
+
+ val result = resolve(url("/page"))
+ val failed = result as FeedResolver.Resolution.Failed
+ assertTrue("should mention it is a web page: '${failed.message}'",
+ failed.message.contains("web page", ignoreCase = true))
+ assertTrue("must not leak parser internals: '${failed.message}'",
+ !failed.message.contains("START_DOCUMENT") && !failed.message.contains("InputStreamReader"))
+ }
+
+ @Test
+ fun `a 404 reports the status rather than a parse error`() = runTest {
+ server.enqueue(MockResponse().setResponseCode(404))
+ val failed = resolve(url("/missing")) as FeedResolver.Resolution.Failed
+ assertTrue(failed.message, failed.message.contains("404"))
+ }
+
+ @Test
+ fun `shared text with surrounding words still resolves`() = runTest {
+ server.enqueue(MockResponse().setBody(feedXml))
+ val result = resolve("Check this out ${url("/feed.xml")} it's good")
+ assertEquals(FeedResolver.Resolution.Feed(url("/feed.xml")), result)
+ }
+
+ @Test
+ fun `empty input fails without a request`() = runTest {
+ val failed = resolve("no link here") as FeedResolver.Resolution.Failed
+ assertEquals(0, server.requestCount)
+ assertTrue(failed.message, failed.message.contains("link", ignoreCase = true))
+ }
+
+ // --- YouTube ---------------------------------------------------------------------------
+
+ @Test
+ fun `a youtube channel resolves without a fetch`() = runTest {
+ val result = resolve("https://www.youtube.com/channel/UCabc123")
+ assertEquals(
+ FeedResolver.Resolution.Feed("https://www.youtube.com/feeds/videos.xml?channel_id=UCabc123"),
+ result,
+ )
+ assertEquals("should not need the network", 0, server.requestCount)
+ }
+
+ /** A handle URL carries no channel id, so say what to do instead of failing opaquely. */
+ @Test
+ fun `a youtube handle explains what is needed`() = runTest {
+ val failed = resolve("https://www.youtube.com/@SomeHandle") as FeedResolver.Resolution.Failed
+ assertTrue("should mention the channel URL: '${failed.message}'",
+ failed.message.contains("channel", ignoreCase = true))
+ }
+
+ // --- Apple Podcasts --------------------------------------------------------------------
+
+ /** Apple Podcasts links are a very common share and are not feeds. */
+ @Test
+ fun `an apple podcasts link resolves through the lookup api`() = runTest {
+ server.enqueue(
+ MockResponse().setBody(
+ """{"resultCount":1,"results":[{"feedUrl":"https://feeds.example.com/show"}]}"""
+ )
+ )
+ val resolverWithLookup = FeedResolver(OkHttpClient(), lookupBase = url("/lookup"))
+
+ val result = resolverWithLookup.resolve("https://podcasts.apple.com/gb/podcast/x/id1537788786")
+ assertEquals(FeedResolver.Resolution.Feed("https://feeds.example.com/show"), result)
+ }
+
+ @Test
+ fun `an apple link with no result fails understandably`() = runTest {
+ server.enqueue(MockResponse().setBody("""{"resultCount":0,"results":[]}"""))
+ val resolverWithLookup = FeedResolver(OkHttpClient(), lookupBase = url("/lookup"))
+
+ val failed = resolverWithLookup.resolve("https://podcasts.apple.com/gb/podcast/x/id999")
+ as FeedResolver.Resolution.Failed
+ assertTrue(failed.message, failed.message.contains("Apple", ignoreCase = true))
+ }
+
+ @Test
+ fun `the apple lookup sends the podcast id`() = runTest {
+ server.enqueue(
+ MockResponse().setBody("""{"resultCount":1,"results":[{"feedUrl":"https://f/x"}]}""")
+ )
+ val resolverWithLookup = FeedResolver(OkHttpClient(), lookupBase = url("/lookup"))
+ resolverWithLookup.resolve("https://podcasts.apple.com/us/podcast/id1537788786")
+
+ val request = server.takeRequest()
+ assertTrue(request.path!!, request.path!!.contains("1537788786"))
+ }
+}