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/Repositories.kt1
-rw-r--r--app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt22
-rw-r--r--app/src/main/java/org/terst/listen/download/ChaquopyAudioSource.kt12
-rw-r--r--app/src/main/java/org/terst/listen/download/EpisodeDownloader.kt4
-rw-r--r--app/src/test/java/org/terst/listen/ReachabilityTest.kt3
-rw-r--r--app/src/test/java/org/terst/listen/data/SubscriptionRepositoryTest.kt52
6 files changed, 89 insertions, 5 deletions
diff --git a/app/src/main/java/org/terst/listen/data/Repositories.kt b/app/src/main/java/org/terst/listen/data/Repositories.kt
index 6604a10..8c994a1 100644
--- a/app/src/main/java/org/terst/listen/data/Repositories.kt
+++ b/app/src/main/java/org/terst/listen/data/Repositories.kt
@@ -79,6 +79,7 @@ class AppGraph(private val context: Context) {
episodes,
FeedFetcher(client),
onDiscovered = { ruleApplier.apply(it) },
+ probe = { url -> ChaquopyAudioSource(context).probe(url) },
)
}
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 5cdc9d4..0e104d4 100644
--- a/app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt
+++ b/app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt
@@ -14,6 +14,9 @@ sealed interface RefreshOutcome {
data class Failed(val message: String) : RefreshOutcome
}
+/** What the extractor knows about a video that its page would not say. */
+data class ProbedVideo(val title: String?, val durationMs: Long?)
+
/** Outcome of walking a feed's RFC 5005 archive pages. */
sealed interface ArchiveOutcome {
/** [stoppedEarly] is null when the walk reached the end of the chain. */
@@ -38,6 +41,15 @@ class SubscriptionRepository(
* is. Deliberately not invoked by [subscribe] or [addSingleEpisode]: see their comments.
*/
private val onDiscovered: suspend (List<EpisodeEntity>) -> Unit = {},
+ /**
+ * Asks the extractor what a video is, for the cases where its page will not say.
+ *
+ * Facebook serves no `og:` tags to a non-browser at all, so a shared video would otherwise
+ * be filed as "Video 10153231379946729". yt-dlp knows, and is going to be run against the
+ * URL anyway. A collaborator rather than a direct call because it reaches the embedded
+ * Python, which cannot run in a unit test.
+ */
+ private val probe: suspend (String) -> ProbedVideo? = { null },
private val now: () -> Long = System::currentTimeMillis,
) {
@@ -178,6 +190,10 @@ class SubscriptionRepository(
subscriptions.getByFeedUrl(feedUrl) ?: entity
}
+ // Only when the page gave nothing: a title that was shared is the one that was meant,
+ // and this costs a network round trip.
+ val probed = if (video.title.isNullOrBlank()) probe(video.watchUrl) else null
+
// insertAll ignores a clashing (subscriptionId, guid), so re-adding the same video is a
// no-op rather than a duplicate.
episodes.insertAll(
@@ -186,11 +202,13 @@ class SubscriptionRepository(
id = UUID.randomUUID().toString(),
subscriptionId = container.id,
guid = if (facebook) "fb:video:${video.videoId}" else "yt:video:${video.videoId}",
- title = video.title?.takeIf { it.isNotBlank() } ?: "Video ${video.videoId}",
+ title = video.title?.takeIf { it.isNotBlank() }
+ ?: probed?.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,
+ durationMs = video.durationMs ?: probed?.durationMs,
publishedAtMs = now(),
imageUrl = video.imageUrl,
discoveredAtMs = now(),
diff --git a/app/src/main/java/org/terst/listen/download/ChaquopyAudioSource.kt b/app/src/main/java/org/terst/listen/download/ChaquopyAudioSource.kt
index 88038f6..de6d2b7 100644
--- a/app/src/main/java/org/terst/listen/download/ChaquopyAudioSource.kt
+++ b/app/src/main/java/org/terst/listen/download/ChaquopyAudioSource.kt
@@ -3,6 +3,7 @@ package org.terst.listen.download
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
+import org.terst.listen.data.ProbedVideo
import org.terst.listen.youtube.YouTubeAudio
import java.io.File
@@ -21,4 +22,15 @@ class ChaquopyAudioSource(private val context: Context) : ExtractedAudioSource {
YouTubeAudio.start(context)
YouTubeAudio.download(url, targetDir)
}
+
+ /**
+ * Best effort, and allowed to fail: this only supplies a nicer title than "Video <id>",
+ * and a video that cannot be probed may still download perfectly well.
+ */
+ override suspend fun probe(url: String): ProbedVideo? = withContext(Dispatchers.IO) {
+ YouTubeAudio.start(context)
+ runCatching { YouTubeAudio.probe(url) }
+ .map { ProbedVideo(title = it.title, durationMs = it.durationMs) }
+ .getOrNull()
+ }
}
diff --git a/app/src/main/java/org/terst/listen/download/EpisodeDownloader.kt b/app/src/main/java/org/terst/listen/download/EpisodeDownloader.kt
index e671aa8..831708f 100644
--- a/app/src/main/java/org/terst/listen/download/EpisodeDownloader.kt
+++ b/app/src/main/java/org/terst/listen/download/EpisodeDownloader.kt
@@ -7,6 +7,7 @@ import okhttp3.Request
import org.terst.listen.data.DownloadState
import org.terst.listen.data.EpisodeDao
import org.terst.listen.data.EpisodeEntity
+import org.terst.listen.data.ProbedVideo
import org.terst.listen.data.SubscriptionKind
import java.io.File
import java.io.IOException
@@ -148,4 +149,7 @@ class EpisodeDownloader(
/** Indirection so the Chaquopy-backed downloader can be swapped out in tests. */
interface ExtractedAudioSource {
suspend fun download(url: String, targetDir: File): File
+
+ /** What the extractor knows about a video without downloading it. Null if it cannot say. */
+ suspend fun probe(url: String): ProbedVideo?
}
diff --git a/app/src/test/java/org/terst/listen/ReachabilityTest.kt b/app/src/test/java/org/terst/listen/ReachabilityTest.kt
index 6a33380..da7d9a3 100644
--- a/app/src/test/java/org/terst/listen/ReachabilityTest.kt
+++ b/app/src/test/java/org/terst/listen/ReachabilityTest.kt
@@ -27,9 +27,6 @@ class ReachabilityTest {
"YouTubeAudio.ytDlpVersion" to
"Called from YouTubeAudioTest, which fails on a stale bundled yt-dlp. That guard " +
"is the whole point; it has no job in production.",
- "YouTubeAudio.probe" to
- "Metadata without downloading. Unused while the only YouTube path is download, but " +
- "the resolver will want it for episode durations.",
)
private val mainDir = File("src/main/java/org/terst/listen")
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 a5544e7..3508fa3 100644
--- a/app/src/test/java/org/terst/listen/data/SubscriptionRepositoryTest.kt
+++ b/app/src/test/java/org/terst/listen/data/SubscriptionRepositoryTest.kt
@@ -265,6 +265,58 @@ class SubscriptionRepositoryTest {
assertEquals(1, eps.rows.size)
}
+ /**
+ * Facebook serves no og: tags to a non-browser at all, so a shared video arrived as
+ * "Video 10153231379946729". yt-dlp knows what it is really called, and is going to be run
+ * against the URL anyway.
+ */
+ @Test
+ fun `a video the page could not name is named by the extractor`() = runTest {
+ val probing = SubscriptionRepository(
+ subs,
+ eps,
+ FeedFetcher(OkHttpClient()),
+ probe = { ProbedVideo(title = "Sheep dog at work", durationMs = 92_000) },
+ ) { clock }
+
+ probing.addSingleEpisode(video("111", null, kind = SubscriptionKind.FACEBOOK_VIDEO))
+
+ val episode = eps.rows.single()
+ assertEquals("Sheep dog at work", episode.title)
+ assertEquals(92_000L, episode.durationMs)
+ }
+
+ /** A title the page did give is the one that was shared; the extractor does not overrule it. */
+ @Test
+ fun `a video the page named keeps that name`() = runTest {
+ val probing = SubscriptionRepository(
+ subs,
+ eps,
+ FeedFetcher(OkHttpClient()),
+ probe = { ProbedVideo(title = "Something else", durationMs = 1) },
+ ) { clock }
+
+ probing.addSingleEpisode(video("aaa", "From the page"))
+
+ assertEquals("From the page", eps.rows.single().title)
+ }
+
+ /** The extractor is allowed to fail -- it is a network call for a nicety. */
+ @Test
+ fun `a video still gets added when the extractor cannot name it`() = runTest {
+ val probing = SubscriptionRepository(
+ subs,
+ eps,
+ FeedFetcher(OkHttpClient()),
+ probe = { null },
+ ) { clock }
+
+ probing.addSingleEpisode(video("111", null, kind = SubscriptionKind.FACEBOOK_VIDEO))
+
+ assertEquals(1, eps.rows.size)
+ assertTrue("a blank title would render as an empty row", eps.rows.single().title.isNotBlank())
+ }
+
@Test
fun `a video with no title still gets one`() = runTest {
repo.addSingleEpisode(video("aaa", null))