From d9585c40e7f3b6de8b5f0c37e27edec49d565aa8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 08:13:12 +0000 Subject: Add single videos, make the whole episode row tappable, stamp the build Sharing a YouTube video now adds that one video rather than subscribing to its channel -- sharing something interesting is not a request to follow everything the channel ever posts. They land in one "Saved videos" container instead of a subscription each, which would otherwise fill the library with entries that will never publish again. Title, artwork and duration come off the watch page we already fetch, so adding needs no extractor. Only the episode title was tappable, which is too fine a target: Peter tapped the row, nothing happened, and reasonably concluded the details screen wasn't there. The whole row opens it now. Settings shows the commit and build time. A cached browser download and a failed publish look identical from the outside, and "is this the new build?" had no answer that did not amount to trusting me. publish-apk writes a companion .txt with the hash for the same reason. Metadata is entity-decoded. A YouTube thumbnail URL carries query parameters and the raw attribute encodes the separators as &, so the artwork simply failed to load -- spotted in the real resolved output rather than by a test. YouTube URL handling moved to its own YouTubeUrls object. Channel, playlist, handle and watch URLs each need different treatment and two only resolve by reading the page; that is its own concern, not a corner of the general resolver. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B --- .../terst/listen/data/SubscriptionRepository.kt | 49 +++++++++ .../java/org/terst/listen/feed/FeedResolver.kt | 37 ++++++- .../org/terst/listen/feed/SubscriptionResolver.kt | 52 --------- .../main/java/org/terst/listen/feed/YouTubeUrls.kt | 117 +++++++++++++++++++++ .../main/java/org/terst/listen/ui/EpisodeRow.kt | 17 +-- .../java/org/terst/listen/ui/ListenViewModel.kt | 8 +- .../java/org/terst/listen/ui/SettingsScreen.kt | 18 ++++ .../org/terst/listen/ui/ShareReceiverActivity.kt | 8 +- .../listen/data/SubscriptionRepositoryTest.kt | 62 +++++++++++ .../java/org/terst/listen/feed/FeedResolverTest.kt | 2 +- .../terst/listen/feed/SubscriptionResolverTest.kt | 99 +++++++++++++---- 11 files changed, 380 insertions(+), 89 deletions(-) create mode 100644 app/src/main/java/org/terst/listen/feed/YouTubeUrls.kt (limited to 'app/src') 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 756ac37..b1b2f18 100644 --- a/app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt +++ b/app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt @@ -1,6 +1,7 @@ package org.terst.listen.data import org.terst.listen.feed.FeedFetchResult +import org.terst.listen.feed.FeedResolver import org.terst.listen.feed.FeedFetcher import org.terst.listen.feed.ParsedFeed import java.util.UUID @@ -12,6 +13,9 @@ sealed interface RefreshOutcome { data class Failed(val message: String) : RefreshOutcome } +private const val SAVED_FEED_URL = "listen:saved" +private const val SAVED_TITLE = "Saved videos" + class SubscriptionRepository( private val subscriptions: SubscriptionDao, private val episodes: EpisodeDao, @@ -85,6 +89,51 @@ class SubscriptionRepository( return RefreshOutcome.Updated(result.inserts.size, result.updates.size) } + /** + * Adds one video as a standalone episode. + * + * Everything lands in a single container subscription rather than one per video: adding + * five would otherwise put five entries in the library that will never publish again. The + * container is marked YOUTUBE_CHANNEL so downloads route through yt-dlp, and its feed URL + * is a local sentinel because there is no feed to refresh. + */ + suspend fun addSingleEpisode(video: FeedResolver.Resolution.SingleEpisode): SubscriptionEntity { + val container = subscriptions.getByFeedUrl(SAVED_FEED_URL) ?: run { + val entity = SubscriptionEntity( + id = UUID.randomUUID().toString(), + feedUrl = SAVED_FEED_URL, + kind = SubscriptionKind.YOUTUBE_CHANNEL, + title = SAVED_TITLE, + description = "Individual videos added by sharing a link", + author = video.author, + addedAtMs = now(), + ) + subscriptions.insert(entity) + subscriptions.getByFeedUrl(SAVED_FEED_URL) ?: entity + } + + // insertAll ignores a clashing (subscriptionId, guid), so re-adding the same video is a + // no-op rather than a duplicate. + episodes.insertAll( + listOf( + EpisodeEntity( + id = UUID.randomUUID().toString(), + subscriptionId = container.id, + guid = "yt:video:${video.videoId}", + title = video.title?.takeIf { it.isNotBlank() } ?: "Video ${video.videoId}", + mediaUrl = video.watchUrl, + // yt-dlp is asked for bestaudio, which comes back as m4a. + mediaType = "audio/mp4", + durationMs = video.durationMs, + publishedAtMs = now(), + imageUrl = video.imageUrl, + discoveredAtMs = now(), + ) + ) + ) + return container + } + private fun ParsedFeed.toSubscription(feedUrl: String, kind: SubscriptionKind, nowMs: Long) = SubscriptionEntity( id = UUID.randomUUID().toString(), diff --git a/app/src/main/java/org/terst/listen/feed/FeedResolver.kt b/app/src/main/java/org/terst/listen/feed/FeedResolver.kt index 07b0125..2cc164b 100644 --- a/app/src/main/java/org/terst/listen/feed/FeedResolver.kt +++ b/app/src/main/java/org/terst/listen/feed/FeedResolver.kt @@ -26,6 +26,22 @@ class FeedResolver( sealed interface Resolution { data class Feed(val url: String) : Resolution + + /** + * One video, to be added on its own. + * + * Sharing a single interesting video is not a request to follow everything its channel + * ever posts, so a watch URL adds the episode rather than subscribing. + */ + data class SingleEpisode( + val watchUrl: String, + val videoId: String, + val title: String?, + val imageUrl: String?, + val durationMs: Long?, + val author: String?, + ) : Resolution + data class Failed(val message: String) : Resolution } @@ -34,15 +50,15 @@ class FeedResolver( ?: sharedText.trim().takeIf { it.startsWith("http", ignoreCase = true) } ?: return Resolution.Failed("No link found in that text.") - if (SubscriptionResolver.isYouTube(url)) return resolveYouTube(url) + if (YouTubeUrls.isYouTube(url)) return resolveYouTube(url) SubscriptionResolver.applePodcastId(url)?.let { return resolveApple(it) } return resolveWeb(url) } private suspend fun resolveYouTube(url: String): Resolution = withContext(Dispatchers.IO) { - if (!SubscriptionResolver.needsChannelLookup(url)) { + if (!YouTubeUrls.needsChannelLookup(url)) { // The URL already names a channel or playlist. - return@withContext Resolution.Feed(SubscriptionResolver.youtubeFeedUrl(url)!!) + return@withContext Resolution.Feed(YouTubeUrls.youtubeFeedUrl(url)!!) } // A handle, /c/, /user/ or watch URL carries no channel id, and those are exactly the @@ -55,7 +71,20 @@ class FeedResolver( return@withContext Resolution.Failed(e.message ?: "Could not reach YouTube.") } - val channelId = page?.let(SubscriptionResolver::channelIdFrom) + // A watch URL names one video, so add just that rather than its whole channel. + YouTubeUrls.youtubeVideoId(url)?.let { videoId -> + val meta = page?.let(YouTubeUrls::videoMetadataFrom) + return@withContext Resolution.SingleEpisode( + watchUrl = "https://www.youtube.com/watch?v=$videoId", + videoId = videoId, + title = meta?.title, + imageUrl = meta?.imageUrl, + durationMs = meta?.durationMs, + author = meta?.author, + ) + } + + val channelId = page?.let(YouTubeUrls::channelIdFrom) ?: return@withContext Resolution.Failed( // YouTube serves a 2KB stub to non-browser user agents on watch pages, so a // video link yields nothing to read. Not worked around by claiming to be a diff --git a/app/src/main/java/org/terst/listen/feed/SubscriptionResolver.kt b/app/src/main/java/org/terst/listen/feed/SubscriptionResolver.kt index fb0e13d..d77c74a 100644 --- a/app/src/main/java/org/terst/listen/feed/SubscriptionResolver.kt +++ b/app/src/main/java/org/terst/listen/feed/SubscriptionResolver.kt @@ -16,21 +16,6 @@ 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-]+)""") - - /** - * A channel id as it appears on a YouTube page. - * - * Three shapes because the pages differ: a channel page carries the canonical link and an - * itemprop, a watch page only embeds it in JSON. - */ - private val CHANNEL_ID_IN_PAGE = listOf( - Regex("""itemprop="identifier"\s+content="(UC[\w-]{10,})""""), - Regex(""""channelId"\s*:\s*"(UC[\w-]{10,})""""), - Regex("""/channel/(UC[\w-]{10,})"""), - ) - private val FEED_TYPES = setOf( "application/rss+xml", "application/atom+xml", @@ -52,43 +37,6 @@ object SubscriptionResolver { 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 - } - - /** - * Pulls a channel id out of a fetched YouTube page. - * - * Needed because the links YouTube's own share sheet produces -- a handle, a watch URL -- - * carry no channel id at all, and rejecting them was the whole of "sharing a YouTube - * channel is absolutely impossible". - */ - fun channelIdFrom(html: String): String? = - CHANNEL_ID_IN_PAGE.firstNotNullOfOrNull { it.find(html)?.groupValues?.get(1) } - - /** A YouTube URL whose channel id can only come from fetching the page. */ - fun needsChannelLookup(url: String): Boolean = - isYouTube(url) && youtubeFeedUrl(url) == null - /** * Finds a feed declared by a show's web page. * diff --git a/app/src/main/java/org/terst/listen/feed/YouTubeUrls.kt b/app/src/main/java/org/terst/listen/feed/YouTubeUrls.kt new file mode 100644 index 0000000..0200a83 --- /dev/null +++ b/app/src/main/java/org/terst/listen/feed/YouTubeUrls.kt @@ -0,0 +1,117 @@ +package org.terst.listen.feed + +import java.net.URI + +/** + * YouTube URL shapes, and what can be read off a YouTube page. + * + * Split out of SubscriptionResolver once this grew its own surface: channel, playlist, handle + * and watch URLs each need different treatment, and two of them only resolve by reading the + * page. Pure and network-free, so every shape is testable without a device. + */ +object YouTubeUrls { + private val CHANNEL_ID_IN_PAGE = listOf( + Regex("""itemprop="identifier"\s+content="(UC[\w-]{10,})""""), + Regex(""""channelId"\s*:\s*"(UC[\w-]{10,})""""), + Regex("""/channel/(UC[\w-]{10,})"""), + ) + + private val VIDEO_AUTHOR = Regex(""""author"\s*:\s*"([^"]{1,80})"""") + + private val LENGTH_SECONDS = Regex(""""lengthSeconds"\s*:\s*"(\d+)"""") + private val OG_IMAGE = Regex("""") + + private const val MS_PER_SECOND = 1000L + + /** + * Pulls a channel id out of a fetched YouTube page. + * + * Needed because the links YouTube's own share sheet produces -- a handle, a watch URL -- + * carry no channel id at all, and rejecting them was the whole of "sharing a YouTube + * channel is absolutely impossible". + */ + fun channelIdFrom(html: String): String? = + CHANNEL_ID_IN_PAGE.firstNotNullOfOrNull { it.find(html)?.groupValues?.get(1) } + + /** A YouTube URL whose channel id can only come from fetching the page. */ + fun needsChannelLookup(url: String): Boolean = + isYouTube(url) && youtubeFeedUrl(url) == null + + private fun hostOf(url: String): String? = + runCatching { URI(url).host?.lowercase() }.getOrNull() +} 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 99fe053..80c282c 100644 --- a/app/src/main/java/org/terst/listen/ui/EpisodeRow.kt +++ b/app/src/main/java/org/terst/listen/ui/EpisodeRow.kt @@ -42,7 +42,15 @@ import java.util.concurrent.TimeUnit */ @Composable internal fun EpisodeRow(episode: EpisodeEntity, actions: EpisodeActions, artworkUrl: String? = null) { - Row(Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 4.dp)) { + // The whole row opens the episode, not just the title text. Making only the text tappable + // was too fine a target -- the natural gesture is a tap anywhere on the row, and it did + // nothing. The action icons below sit above this and keep their own handlers. + Row( + Modifier + .fillMaxWidth() + .clickable(onClick = actions.openDetails) + .padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 4.dp) + ) { AsyncImage( model = artworkUrl, contentDescription = null, @@ -54,12 +62,7 @@ internal fun EpisodeRow(episode: EpisodeEntity, actions: EpisodeActions, artwork Column(Modifier.weight(1f).padding(start = 12.dp)) { // Tapping the title opens the episode: the list can only show a couple of lines and // the description has nowhere else to go. - Text( - episode.title, - style = MaterialTheme.typography.titleSmall, - maxLines = 2, - modifier = Modifier.clickable(onClick = actions.openDetails), - ) + Text(episode.title, style = MaterialTheme.typography.titleSmall, maxLines = 2) Text(episode.progressLabel(), style = MaterialTheme.typography.bodySmall) Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { 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 f439a2f..64dfec9 100644 --- a/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt +++ b/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt @@ -16,6 +16,7 @@ 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 org.terst.listen.feed.YouTubeUrls import android.net.Uri import org.terst.listen.opml.Opml import org.terst.listen.opml.OpmlFeed @@ -116,8 +117,13 @@ class ListenViewModel(app: Application) : AndroidViewModel(app) { is FeedResolver.Resolution.Failed -> _banner.value = Banner(resolved.message, isError = true) + is FeedResolver.Resolution.SingleEpisode -> { + graph.repository.addSingleEpisode(resolved) + _banner.value = Banner("Added ${resolved.title ?: "video"}") + } + is FeedResolver.Resolution.Feed -> { - val kind = if (SubscriptionResolver.isYouTube(resolved.url)) { + val kind = if (YouTubeUrls.isYouTube(resolved.url)) { SubscriptionKind.YOUTUBE_CHANNEL } else { SubscriptionKind.PODCAST diff --git a/app/src/main/java/org/terst/listen/ui/SettingsScreen.kt b/app/src/main/java/org/terst/listen/ui/SettingsScreen.kt index a16e3d5..6ddcc92 100644 --- a/app/src/main/java/org/terst/listen/ui/SettingsScreen.kt +++ b/app/src/main/java/org/terst/listen/ui/SettingsScreen.kt @@ -73,6 +73,8 @@ fun SettingsScreen(vm: ListenViewModel, player: PlayerViewModel, onBack: () -> U HorizontalDivider() SleepSection(remainingMs = sleepRemaining, onArm = player::armSleepTimer, onCancel = player::cancelSleepTimer) HorizontalDivider() + BuildSection() + HorizontalDivider() SubscriptionsSection( onImport = { importLauncher.launch(OPML_MIME_TYPES) }, onExport = { exportLauncher.launch("listen-subscriptions.opml") }, @@ -134,6 +136,22 @@ private fun SubscriptionsSection(onImport: () -> Unit, onExport: () -> Unit) { } } +/** + * Which build this is. + * + * A cached download and a failed publish look identical from the browser, so the only honest + * answer to "did the update actually land?" comes from the app itself. + */ +@Composable +private fun BuildSection() { + Section("Build") { + Text( + "${org.terst.listen.BuildConfig.GIT_COMMIT} ยท built ${org.terst.listen.BuildConfig.BUILD_TIME} UTC", + style = MaterialTheme.typography.bodySmall, + ) + } +} + @Composable private fun Section(title: String, content: @Composable () -> Unit) { Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { 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 d9d80fe..4c6c637 100644 --- a/app/src/main/java/org/terst/listen/ui/ShareReceiverActivity.kt +++ b/app/src/main/java/org/terst/listen/ui/ShareReceiverActivity.kt @@ -10,6 +10,7 @@ import org.terst.listen.data.Graph import org.terst.listen.data.SubscriptionKind import org.terst.listen.feed.FeedResolver import org.terst.listen.feed.SubscriptionResolver +import org.terst.listen.feed.YouTubeUrls /** * Share-sheet target. @@ -39,8 +40,13 @@ class ShareReceiverActivity : ComponentActivity() { when (val resolved = graph.resolver.resolve(shared)) { is FeedResolver.Resolution.Failed -> reportAndFinish(resolved.message) + is FeedResolver.Resolution.SingleEpisode -> { + graph.repository.addSingleEpisode(resolved) + reportAndFinish("Added ${resolved.title ?: "video"}") + } + is FeedResolver.Resolution.Feed -> { - val kind = if (SubscriptionResolver.isYouTube(resolved.url)) { + val kind = if (YouTubeUrls.isYouTube(resolved.url)) { SubscriptionKind.YOUTUBE_CHANNEL } else { SubscriptionKind.PODCAST 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 2008363..0d56c56 100644 --- a/app/src/test/java/org/terst/listen/data/SubscriptionRepositoryTest.kt +++ b/app/src/test/java/org/terst/listen/data/SubscriptionRepositoryTest.kt @@ -153,6 +153,68 @@ class SubscriptionRepositoryTest { assertEquals("Renamed Show", subs.rows.single().title) } + private fun video( + id: String, + title: String?, + imageUrl: String? = null, + durationMs: Long? = null, + author: String? = null, + ) = org.terst.listen.feed.FeedResolver.Resolution.SingleEpisode( + watchUrl = "https://www.youtube.com/watch?v=$id", + videoId = id, + title = title, + imageUrl = imageUrl, + durationMs = durationMs, + author = author, + ) + + // --- single episodes ------------------------------------------------------------------ + + /** + * A shared container rather than one subscription per video: adding five videos should not + * produce five entries in the library that will never publish anything again. + */ + @Test + fun `a single video lands in a shared container`() = runTest { + repo.addSingleEpisode(video("aaa", "First")) + repo.addSingleEpisode(video("bbb", "Second")) + + assertEquals("one container, not one per video", 1, subs.rows.size) + assertEquals(2, eps.rows.size) + } + + @Test + fun `a single video keeps its metadata`() = runTest { + repo.addSingleEpisode( + video("aaa", "Feynman on gravitation", "https://img/1.jpg", 3337_000, "Der Atlantik Report") + ) + val episode = eps.rows.single() + assertEquals("Feynman on gravitation", episode.title) + assertEquals("https://img/1.jpg", episode.imageUrl) + assertEquals(3337_000L, episode.durationMs) + assertEquals("https://www.youtube.com/watch?v=aaa", episode.mediaUrl) + } + + /** The container downloads through yt-dlp, so it has to be marked as a YouTube source. */ + @Test + fun `the container is a youtube subscription`() = runTest { + repo.addSingleEpisode(video("aaa", null)) + assertEquals(SubscriptionKind.YOUTUBE_CHANNEL, subs.rows.single().kind) + } + + @Test + fun `adding the same video twice does not duplicate it`() = runTest { + repo.addSingleEpisode(video("aaa", "First")) + repo.addSingleEpisode(video("aaa", "First")) + assertEquals(1, eps.rows.size) + } + + @Test + fun `a video with no title still gets one`() = runTest { + repo.addSingleEpisode(video("aaa", null)) + assertTrue("a blank title would render as an empty row", eps.rows.single().title.isNotBlank()) + } + @Test fun `refreshAll reports per subscription`() = runTest { server.enqueue(MockResponse().setBody(feed())) diff --git a/app/src/test/java/org/terst/listen/feed/FeedResolverTest.kt b/app/src/test/java/org/terst/listen/feed/FeedResolverTest.kt index 8967c49..16eddaf 100644 --- a/app/src/test/java/org/terst/listen/feed/FeedResolverTest.kt +++ b/app/src/test/java/org/terst/listen/feed/FeedResolverTest.kt @@ -191,7 +191,7 @@ class FeedResolverTest { */ @Test fun `a handle url is recognised as needing a page lookup`() = runTest { - assertTrue(SubscriptionResolver.needsChannelLookup("https://youtube.com/@lastweektonight?si=abc")) + assertTrue(YouTubeUrls.needsChannelLookup("https://youtube.com/@lastweektonight?si=abc")) assertEquals(0, server.requestCount) } diff --git a/app/src/test/java/org/terst/listen/feed/SubscriptionResolverTest.kt b/app/src/test/java/org/terst/listen/feed/SubscriptionResolverTest.kt index 638816d..e823eb6 100644 --- a/app/src/test/java/org/terst/listen/feed/SubscriptionResolverTest.kt +++ b/app/src/test/java/org/terst/listen/feed/SubscriptionResolverTest.kt @@ -72,38 +72,38 @@ class SubscriptionResolverTest { */ @Test fun `builds a channel feed url from a channel id`() { - val resolved = SubscriptionResolver.youtubeFeedUrl("https://www.youtube.com/channel/UCabc123") + val resolved = YouTubeUrls.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") + val resolved = YouTubeUrls.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")) + assertNull(YouTubeUrls.youtubeFeedUrl("https://www.youtube.com/@SomeHandle")) } @Test fun `a plain video url is not a channel feed`() { - assertNull(SubscriptionResolver.youtubeFeedUrl("https://youtu.be/Bo9Z29YjzVk")) + assertNull(YouTubeUrls.youtubeFeedUrl("https://youtu.be/Bo9Z29YjzVk")) } @Test fun `non-youtube urls are not channel feeds`() { - assertNull(SubscriptionResolver.youtubeFeedUrl("https://example.com/channel/UCabc")) + assertNull(YouTubeUrls.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")) + assertTrue(YouTubeUrls.isYouTube("https://www.youtube.com/watch?v=abc")) + assertTrue(YouTubeUrls.isYouTube("https://youtu.be/abc")) + assertTrue(YouTubeUrls.isYouTube("https://m.youtube.com/watch?v=abc")) + assertTrue(!YouTubeUrls.isYouTube("https://example.com/youtube")) } /** @@ -115,47 +115,100 @@ class SubscriptionResolverTest { fun `extracts a channel id from a handle page`() { val html = """ """ - assertEquals("UC3XTzVzaHQEd30rQbuvCtTQ", SubscriptionResolver.channelIdFrom(html)) + assertEquals("UC3XTzVzaHQEd30rQbuvCtTQ", YouTubeUrls.channelIdFrom(html)) } @Test fun `extracts a channel id from a watch page`() { val html = """""" - assertEquals("UCW7FMS1tAzMHM7eE14fbXcg", SubscriptionResolver.channelIdFrom(html)) + assertEquals("UCW7FMS1tAzMHM7eE14fbXcg", YouTubeUrls.channelIdFrom(html)) } @Test fun `extracts a channel id from a canonical link alone`() { val html = """""" - assertEquals("UCabc123_-xYZ01234567890", SubscriptionResolver.channelIdFrom(html)) + assertEquals("UCabc123_-xYZ01234567890", YouTubeUrls.channelIdFrom(html)) } @Test fun `returns null when a page carries no channel id`() { - assertNull(SubscriptionResolver.channelIdFrom("nothing here")) - assertNull(SubscriptionResolver.channelIdFrom("")) + assertNull(YouTubeUrls.channelIdFrom("nothing here")) + assertNull(YouTubeUrls.channelIdFrom("")) } /** Only real channel ids: they always start UC and are otherwise word characters. */ @Test fun `ignores things that merely look like a channel id`() { - assertNull(SubscriptionResolver.channelIdFrom("""x""")) + assertNull(YouTubeUrls.channelIdFrom("""x""")) } @Test fun `a handle url is recognised as needing a lookup`() { - assertTrue(SubscriptionResolver.needsChannelLookup("https://youtube.com/@lastweektonight?si=abc")) - assertTrue(SubscriptionResolver.needsChannelLookup("https://www.youtube.com/c/somename")) - assertTrue(SubscriptionResolver.needsChannelLookup("https://www.youtube.com/user/somename")) - assertTrue(SubscriptionResolver.needsChannelLookup("https://www.youtube.com/watch?v=abc123")) - assertTrue(SubscriptionResolver.needsChannelLookup("https://youtu.be/abc123")) + assertTrue(YouTubeUrls.needsChannelLookup("https://youtube.com/@lastweektonight?si=abc")) + assertTrue(YouTubeUrls.needsChannelLookup("https://www.youtube.com/c/somename")) + assertTrue(YouTubeUrls.needsChannelLookup("https://www.youtube.com/user/somename")) + assertTrue(YouTubeUrls.needsChannelLookup("https://www.youtube.com/watch?v=abc123")) + assertTrue(YouTubeUrls.needsChannelLookup("https://youtu.be/abc123")) } @Test fun `urls that already carry an id need no lookup`() { - assertTrue(!SubscriptionResolver.needsChannelLookup("https://m.youtube.com/channel/UCabc")) - assertTrue(!SubscriptionResolver.needsChannelLookup("https://youtube.com/playlist?list=PLabc")) - assertTrue(!SubscriptionResolver.needsChannelLookup("https://example.com/whatever")) + assertTrue(!YouTubeUrls.needsChannelLookup("https://m.youtube.com/channel/UCabc")) + assertTrue(!YouTubeUrls.needsChannelLookup("https://youtube.com/playlist?list=PLabc")) + assertTrue(!YouTubeUrls.needsChannelLookup("https://example.com/whatever")) + } + + /** + * A watch URL should add that one video, not subscribe to the whole channel -- sharing a + * single interesting video is not the same request as following everything the channel + * ever posts. + */ + @Test + fun `recognises a single video url`() { + assertEquals("Bo9Z29YjzVk", YouTubeUrls.youtubeVideoId("https://www.youtube.com/watch?v=Bo9Z29YjzVk")) + assertEquals("Bo9Z29YjzVk", YouTubeUrls.youtubeVideoId("https://youtu.be/Bo9Z29YjzVk?si=abc")) + assertEquals("Bo9Z29YjzVk", YouTubeUrls.youtubeVideoId("https://m.youtube.com/watch?v=Bo9Z29YjzVk&t=30")) + } + + @Test + fun `a channel or playlist is not a single video`() { + assertNull(YouTubeUrls.youtubeVideoId("https://www.youtube.com/channel/UCabc123")) + assertNull(YouTubeUrls.youtubeVideoId("https://youtube.com/playlist?list=PL123")) + assertNull(YouTubeUrls.youtubeVideoId("https://youtube.com/@handle")) + assertNull(YouTubeUrls.youtubeVideoId("https://example.com/watch?v=abc")) + } + + @Test + fun `reads title and artwork from a watch page`() { + val html = """ + + + {"lengthSeconds":"3337","author":"Der Atlantik Report"}""" + val meta = YouTubeUrls.videoMetadataFrom(html) + assertEquals("Richard Feynman -- The Law of Gravitation", meta.title) + assertEquals("https://i.ytimg.com/vi/Bo9Z29YjzVk/hqdefault.jpg", meta.imageUrl) + assertEquals(3337_000L, meta.durationMs) + assertEquals("Der Atlantik Report", meta.author) + } + + /** + * A thumbnail URL carries query parameters, and the raw attribute encodes the separators as + * &. Left undecoded the image never loads -- seen on a real watch page. + */ + @Test + fun `decodes entities in metadata`() { + val html = """ + """ + val meta = YouTubeUrls.videoMetadataFrom(html) + assertEquals("https://i.ytimg.com/vi/x/hq.jpg?sqp=a&rs=b", meta.imageUrl) + assertEquals("Rock & Roll", meta.title) + } + + @Test + fun `a page with no metadata yields nulls rather than throwing`() { + val meta = YouTubeUrls.videoMetadataFrom("") + assertNull(meta.title) + assertNull(meta.durationMs) } // --- HTML feed discovery -------------------------------------------------------------- -- cgit v1.2.3