summaryrefslogtreecommitdiff
path: root/app/src
diff options
context:
space:
mode:
Diffstat (limited to 'app/src')
-rw-r--r--app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt49
-rw-r--r--app/src/main/java/org/terst/listen/feed/FeedResolver.kt37
-rw-r--r--app/src/main/java/org/terst/listen/feed/SubscriptionResolver.kt52
-rw-r--r--app/src/main/java/org/terst/listen/feed/YouTubeUrls.kt117
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeRow.kt17
-rw-r--r--app/src/main/java/org/terst/listen/ui/ListenViewModel.kt8
-rw-r--r--app/src/main/java/org/terst/listen/ui/SettingsScreen.kt18
-rw-r--r--app/src/main/java/org/terst/listen/ui/ShareReceiverActivity.kt8
-rw-r--r--app/src/test/java/org/terst/listen/data/SubscriptionRepositoryTest.kt62
-rw-r--r--app/src/test/java/org/terst/listen/feed/FeedResolverTest.kt2
-rw-r--r--app/src/test/java/org/terst/listen/feed/SubscriptionResolverTest.kt99
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 &amp; -- 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("&amp;", "&")
+ .replace("&quot;", "\"")
+ .replace("&#39;", "'")
+ .replace("&lt;", "<")
+ .replace("&gt;", ">")
+
+ 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
+ * &amp;. 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&amp;rs=b">
+ <meta property="og:title" content="Rock &amp; 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 --------------------------------------------------------------