summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-09-25 08:13:12 +0000
committerClaude <noreply@anthropic.com>2026-09-25 08:13:12 +0000
commitd9585c40e7f3b6de8b5f0c37e27edec49d565aa8 (patch)
tree2b9469f6f8fa8442eb54bee8c31135292f60f71e /app
parent8dc3cbc4569d384c1689c3f9823e40d4f8a277be (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 &amp;, 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')
-rw-r--r--app/build.gradle.kts29
-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
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 &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 --------------------------------------------------------------