diff options
Diffstat (limited to 'app')
12 files changed, 409 insertions, 89 deletions
diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a81df9b..558e1dc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,23 @@ +import java.io.ByteArrayOutputStream +import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter + +/** + * The commit this APK was built from. + * + * Exists so "which build am I running?" is answerable from the phone rather than by trusting + * that a publish worked -- a cached download looks identical to a failed publish. + */ +fun gitDescribe(): String = try { + val out = ByteArrayOutputStream() + providers.exec { + commandLine("git", "describe", "--always", "--dirty", "--abbrev=8") + }.standardOutput.asText.get().trim().ifEmpty { "unknown" } +} catch (e: Exception) { + "unknown" +} + plugins { id("com.android.application") id("org.jetbrains.kotlin.android") @@ -29,6 +49,14 @@ android { versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + buildConfigField("String", "GIT_COMMIT", "\"${gitDescribe()}\"") + buildConfigField( + "String", + "BUILD_TIME", + "\"${DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm") + .withZone(ZoneOffset.UTC).format(Instant.now())}\"", + ) + ndk { // CPython ships as native code, so every ABI included multiplies the APK size. // arm64 is the phone; x86_64 is the emulator. @@ -74,6 +102,7 @@ android { buildFeatures { compose = true + buildConfig = true } compileOptions { 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 -------------------------------------------------------------- |
