From cabe26374ef047c93bcebed83d70923631910fc0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 18:24:16 +0000 Subject: Actually resolve shared links, and guard against dead code Bug report from the phone: pasting a link produced "Could not parse feed: Unexpected . 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 Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B --- .../java/org/terst/listen/data/Repositories.kt | 3 + .../java/org/terst/listen/feed/FeedResolver.kt | 115 ++++++++++++++ .../org/terst/listen/playback/PlayerConnection.kt | 21 ++- .../java/org/terst/listen/ui/ListenViewModel.kt | 36 ++--- .../java/org/terst/listen/ui/PlayerViewModel.kt | 22 ++- .../org/terst/listen/ui/ShareReceiverActivity.kt | 40 +++-- .../test/java/org/terst/listen/ReachabilityTest.kt | 92 +++++++++++ .../java/org/terst/listen/feed/FeedResolverTest.kt | 175 +++++++++++++++++++++ 8 files changed, 463 insertions(+), 41 deletions(-) create mode 100644 app/src/main/java/org/terst/listen/feed/FeedResolver.kt create mode 100644 app/src/test/java/org/terst/listen/ReachabilityTest.kt create mode 100644 app/src/test/java/org/terst/listen/feed/FeedResolverTest.kt (limited to 'app/src') 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 `. + */ +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(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 = 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 = 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, + ): List { + 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 . 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 = """ + + A Showhttps://example.comd + Oneg1 + + """.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 = """ + + + The Show + """.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 = """ + """ + 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("Just a page") + ) + + 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")) + } +} -- cgit v1.2.3