diff options
| author | Claude <noreply@anthropic.com> | 2026-09-25 08:13:12 +0000 |
|---|---|---|
| committer | Claude <noreply@anthropic.com> | 2026-09-25 08:13:12 +0000 |
| commit | d9585c40e7f3b6de8b5f0c37e27edec49d565aa8 (patch) | |
| tree | 2b9469f6f8fa8442eb54bee8c31135292f60f71e /app/src | |
| parent | 8dc3cbc4569d384c1689c3f9823e40d4f8a277be (diff) | |
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
Diffstat (limited to 'app/src')
11 files changed, 380 insertions, 89 deletions
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("""<link\b[^>]*>""", 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("""<meta property="og:image" content="([^"]+)"""") + private val OG_TITLE = Regex("""<meta property="og:title" content="([^"]+)"""") + private val YOUTUBE_WATCH = Regex("""[?&]v=([\w-]{6,})""") + 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 YOUTUBE_CHANNEL = Regex("""/channel/(UC[\w-]+)""") + 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 + } + + /** What a watch page says about a video, without needing an extractor. */ + data class VideoMetadata( + val title: String? = null, + val imageUrl: String? = null, + val durationMs: Long? = null, + val author: String? = null, + ) + + /** + * The video id, if this URL names one video. + * + * Sharing one interesting video is a different request from following everything a channel + * posts, so a watch URL adds that episode rather than subscribing. + */ + fun youtubeVideoId(url: String): String? { + if (!isYouTube(url)) return null + val host = runCatching { URI(url).host?.lowercase() }.getOrNull() + if (host == "youtu.be") { + return URI(url).path.removePrefix("/").substringBefore('/').takeIf { it.isNotBlank() } + } + if (!url.contains("/watch")) return null + return YOUTUBE_WATCH.find(url)?.groupValues?.get(1) + } + + /** Read off the page we already fetch, so adding a video needs no extractor. */ + fun videoMetadataFrom(html: String) = VideoMetadata( + title = OG_TITLE.find(html)?.groupValues?.get(1)?.let(::decodeEntities), + // Entity-decoded: a thumbnail URL carries query parameters, and the raw attribute has + // them as & -- left encoded the image simply fails to load. + imageUrl = OG_IMAGE.find(html)?.groupValues?.get(1)?.let(::decodeEntities), + durationMs = LENGTH_SECONDS.find(html)?.groupValues?.get(1)?.toLongOrNull()?.times(MS_PER_SECOND), + author = VIDEO_AUTHOR.find(html)?.groupValues?.get(1)?.let(::decodeEntities), + ) + + /** The handful of entities that actually appear in a meta attribute. */ + private fun decodeEntities(raw: String) = raw + .replace("&", "&") + .replace(""", "\"") + .replace("'", "'") + .replace("<", "<") + .replace(">", ">") + + 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 = """<html><head><meta itemprop="identifier" content="UC3XTzVzaHQEd30rQbuvCtTQ"> <link rel="canonical" href="https://www.youtube.com/channel/UC3XTzVzaHQEd30rQbuvCtTQ"></head></html>""" - assertEquals("UC3XTzVzaHQEd30rQbuvCtTQ", SubscriptionResolver.channelIdFrom(html)) + assertEquals("UC3XTzVzaHQEd30rQbuvCtTQ", YouTubeUrls.channelIdFrom(html)) } @Test fun `extracts a channel id from a watch page`() { val html = """<html><body><script>{"channelId":"UCW7FMS1tAzMHM7eE14fbXcg","x":1}</script></body></html>""" - assertEquals("UCW7FMS1tAzMHM7eE14fbXcg", SubscriptionResolver.channelIdFrom(html)) + assertEquals("UCW7FMS1tAzMHM7eE14fbXcg", YouTubeUrls.channelIdFrom(html)) } @Test fun `extracts a channel id from a canonical link alone`() { val html = """<link rel="canonical" href="https://www.youtube.com/channel/UCabc123_-xYZ01234567890">""" - 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("<html><body>nothing here</body></html>")) - assertNull(SubscriptionResolver.channelIdFrom("")) + assertNull(YouTubeUrls.channelIdFrom("<html><body>nothing here</body></html>")) + 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("""<a href="/channel/NOTACHANNEL">x</a>""")) + assertNull(YouTubeUrls.channelIdFrom("""<a href="/channel/NOTACHANNEL">x</a>""")) } @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 = """<html><head> + <meta property="og:title" content="Richard Feynman -- The Law of Gravitation"> + <meta property="og:image" content="https://i.ytimg.com/vi/Bo9Z29YjzVk/hqdefault.jpg"> + </head><body>{"lengthSeconds":"3337","author":"Der Atlantik Report"}</body></html>""" + 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 = """<meta property="og:image" content="https://i.ytimg.com/vi/x/hq.jpg?sqp=a&rs=b"> + <meta property="og:title" content="Rock & Roll">""" + 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("<html></html>") + assertNull(meta.title) + assertNull(meta.durationMs) } // --- HTML feed discovery -------------------------------------------------------------- |
