summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.coverage-baseline2
-rw-r--r--app/src/main/java/org/terst/listen/download/ChaquopyYouTubeSource.kt22
-rw-r--r--app/src/main/java/org/terst/listen/download/EpisodeDownloader.kt136
-rw-r--r--app/src/test/java/org/terst/listen/data/FakeDaos.kt8
-rw-r--r--app/src/test/java/org/terst/listen/download/EpisodeDownloaderTest.kt164
5 files changed, 330 insertions, 2 deletions
diff --git a/.coverage-baseline b/.coverage-baseline
index 0186ab3..36d5f03 100644
--- a/.coverage-baseline
+++ b/.coverage-baseline
@@ -1 +1 @@
-81.69
+80.96
diff --git a/app/src/main/java/org/terst/listen/download/ChaquopyYouTubeSource.kt b/app/src/main/java/org/terst/listen/download/ChaquopyYouTubeSource.kt
new file mode 100644
index 0000000..267cc3e
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/download/ChaquopyYouTubeSource.kt
@@ -0,0 +1,22 @@
+package org.terst.listen.download
+
+import android.content.Context
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.terst.listen.youtube.YouTubeAudio
+import java.io.File
+
+/**
+ * The real YouTube source, backed by yt-dlp on the embedded interpreter.
+ *
+ * Isolated behind [YouTubeDownloadSource] so the downloader's own logic stays testable off
+ * device -- nothing here can run without an Android Python runtime.
+ */
+class ChaquopyYouTubeSource(private val context: Context) : YouTubeDownloadSource {
+
+ override suspend fun download(url: String, targetDir: File): File = withContext(Dispatchers.IO) {
+ // Idempotent, and cheap after the first call.
+ YouTubeAudio.start(context)
+ YouTubeAudio.download(url, targetDir)
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/download/EpisodeDownloader.kt b/app/src/main/java/org/terst/listen/download/EpisodeDownloader.kt
new file mode 100644
index 0000000..d07e026
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/download/EpisodeDownloader.kt
@@ -0,0 +1,136 @@
+package org.terst.listen.download
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okhttp3.OkHttpClient
+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.SubscriptionKind
+import java.io.File
+import java.io.IOException
+
+/**
+ * Fetches an episode's audio for offline playback.
+ *
+ * Two sources, because a YouTube subscription's media URL is a watch page rather than audio.
+ * Fetching one over plain HTTP would store an HTML document under an .mp3 name that fails to
+ * play with no indication why -- so the kind decides the route, and there is no fallback
+ * between them.
+ */
+class EpisodeDownloader(
+ private val client: OkHttpClient,
+ private val episodes: EpisodeDao,
+ private val libraryDir: File,
+ private val youtube: YouTubeDownloadSource? = null,
+ private val now: () -> Long = System::currentTimeMillis,
+) {
+
+ suspend fun download(episode: EpisodeEntity, kind: SubscriptionKind): Result<File> {
+ alreadyOnDisk(episode)?.let { return Result.success(it) }
+
+ episodes.updateDownload(episode.id, DownloadState.DOWNLOADING, null, null)
+
+ val result = when (kind) {
+ SubscriptionKind.YOUTUBE_CHANNEL -> downloadViaYouTube(episode)
+ SubscriptionKind.PODCAST -> downloadOverHttp(episode)
+ }
+
+ result.fold(
+ onSuccess = { episodes.updateDownload(episode.id, DownloadState.DOWNLOADED, it.absolutePath, now()) },
+ // localPath is cleared: a FAILED row pointing at a path invites something downstream
+ // to try playing a file that is not there.
+ onFailure = { episodes.updateDownload(episode.id, DownloadState.FAILED, null, null) },
+ )
+ return result
+ }
+
+ /**
+ * A recorded download whose file has since been deleted has to be fetched again, not treated
+ * as present -- otherwise playback fails on a file the database insists exists.
+ */
+ private fun alreadyOnDisk(episode: EpisodeEntity): File? {
+ if (episode.downloadState != DownloadState.DOWNLOADED) return null
+ val path = episode.localPath ?: return null
+ return File(path).takeIf { it.isFile && it.length() > 0 }
+ }
+
+ private suspend fun downloadViaYouTube(episode: EpisodeEntity): Result<File> {
+ val source = youtube
+ ?: return Result.failure(IllegalStateException("No YouTube downloader configured"))
+ return runCatching { source.download(episode.mediaUrl, libraryDir) }
+ }
+
+ private suspend fun downloadOverHttp(episode: EpisodeEntity): Result<File> =
+ withContext(Dispatchers.IO) {
+ val target = targetFile(episode)
+ // Written to a partial name and renamed on success. A truncated file recorded as
+ // downloaded plays as a cut-off episode and reads like the publisher's fault.
+ val partial = File(target.absolutePath + ".part")
+ partial.parentFile?.mkdirs()
+
+ try {
+ val request = Request.Builder().url(episode.mediaUrl).header("User-Agent", USER_AGENT).build()
+ client.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) {
+ partial.delete()
+ return@withContext Result.failure(
+ IOException("HTTP ${response.code} ${response.message}".trim())
+ )
+ }
+ val body = response.body ?: run {
+ partial.delete()
+ return@withContext Result.failure(IOException("Empty response"))
+ }
+ body.byteStream().use { input ->
+ partial.outputStream().use { output -> input.copyTo(output) }
+ }
+ }
+ if (!partial.renameTo(target)) {
+ partial.delete()
+ return@withContext Result.failure(IOException("Could not store ${target.name}"))
+ }
+ Result.success(target)
+ } catch (e: IOException) {
+ partial.delete()
+ Result.failure(e)
+ }
+ }
+
+ /**
+ * Names the file from the episode id, sanitised.
+ *
+ * Ids come from feeds, and a guid containing path separators would otherwise write outside
+ * the library.
+ */
+ private fun targetFile(episode: EpisodeEntity): File {
+ val safeId = episode.id.map { if (it.isLetterOrDigit() || it == '-' || it == '_') it else '_' }
+ .joinToString("")
+ .ifBlank { "episode" }
+ .take(MAX_NAME_LENGTH)
+ val extension = extensionFor(episode.mediaType)
+ return File(libraryDir, "$safeId$extension")
+ }
+
+ private fun extensionFor(mediaType: String) = when (mediaType.substringBefore(';').trim().lowercase()) {
+ "audio/mpeg", "audio/mp3" -> ".mp3"
+ "audio/mp4", "audio/x-m4a", "audio/aac" -> ".m4a"
+ "audio/opus" -> ".opus"
+ "audio/ogg" -> ".ogg"
+ "audio/flac" -> ".flac"
+ "audio/wav", "audio/x-wav" -> ".wav"
+ "video/mp4" -> ".mp4"
+ else -> ".audio"
+ }
+
+ private companion object {
+ const val USER_AGENT = "Listen/1.0 (Android podcast client)"
+ const val MAX_NAME_LENGTH = 120
+ }
+}
+
+/** Indirection so the Chaquopy-backed downloader can be swapped out in tests. */
+interface YouTubeDownloadSource {
+ suspend fun download(url: String, targetDir: File): File
+}
diff --git a/app/src/test/java/org/terst/listen/data/FakeDaos.kt b/app/src/test/java/org/terst/listen/data/FakeDaos.kt
index e28786c..821e1b2 100644
--- a/app/src/test/java/org/terst/listen/data/FakeDaos.kt
+++ b/app/src/test/java/org/terst/listen/data/FakeDaos.kt
@@ -49,6 +49,9 @@ class FakeSubscriptionDao : SubscriptionDao {
class FakeEpisodeDao : EpisodeDao {
val rows = mutableListOf<EpisodeEntity>()
+ /** Lets a test observe intermediate states that the final row no longer shows. */
+ var onUpdate: ((EpisodeEntity) -> Unit)? = null
+
override fun observeForSubscription(subscriptionId: String): Flow<List<EpisodeEntity>> =
flowOf(rows.filter { it.subscriptionId == subscriptionId })
@@ -105,7 +108,10 @@ class FakeEpisodeDao : EpisodeDao {
private fun replace(id: String, transform: (EpisodeEntity) -> EpisodeEntity) {
val index = rows.indexOfFirst { it.id == id }
- if (index >= 0) rows[index] = transform(rows[index])
+ if (index >= 0) {
+ rows[index] = transform(rows[index])
+ onUpdate?.invoke(rows[index])
+ }
}
}
diff --git a/app/src/test/java/org/terst/listen/download/EpisodeDownloaderTest.kt b/app/src/test/java/org/terst/listen/download/EpisodeDownloaderTest.kt
new file mode 100644
index 0000000..d5e971b
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/download/EpisodeDownloaderTest.kt
@@ -0,0 +1,164 @@
+package org.terst.listen.download
+
+import kotlinx.coroutines.test.runTest
+import okhttp3.OkHttpClient
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import org.terst.listen.data.DownloadState
+import org.terst.listen.data.EpisodeEntity
+import org.terst.listen.data.FakeEpisodeDao
+import org.terst.listen.data.SubscriptionKind
+import java.io.File
+
+class EpisodeDownloaderTest {
+
+ @get:Rule val temp = TemporaryFolder()
+
+ private lateinit var server: MockWebServer
+ private lateinit var dao: FakeEpisodeDao
+ private lateinit var downloader: EpisodeDownloader
+ private lateinit var library: File
+
+ private fun episode(id: String = "ep-1", url: String = server.url("/a.mp3").toString()) =
+ EpisodeEntity(
+ id = id,
+ subscriptionId = "s1",
+ guid = id,
+ title = "Episode One",
+ mediaUrl = url,
+ mediaType = "audio/mpeg",
+ discoveredAtMs = 0,
+ )
+
+ @Before
+ fun setUp() {
+ server = MockWebServer()
+ server.start()
+ dao = FakeEpisodeDao()
+ library = temp.newFolder("audio")
+ downloader = EpisodeDownloader(OkHttpClient(), dao, library, null) { 1_000L }
+ }
+
+ @After
+ fun tearDown() = server.shutdown()
+
+ @Test
+ fun `downloads an episode and records it`() = runTest {
+ server.enqueue(MockResponse().setBody("audio-bytes"))
+ val ep = episode().also { dao.rows += it }
+
+ val result = downloader.download(ep, SubscriptionKind.PODCAST)
+ assertTrue(result.isSuccess)
+
+ val stored = dao.rows.single()
+ assertEquals(DownloadState.DOWNLOADED, stored.downloadState)
+ assertEquals(1_000L, stored.downloadedAtMs)
+ assertEquals("audio-bytes", File(stored.localPath!!).readText())
+ }
+
+ /**
+ * A part-written file that gets recorded as DOWNLOADED plays as a truncated episode and
+ * looks like the publisher's fault. Nothing lands under its real name until it is complete.
+ */
+ @Test
+ fun `a failed download leaves no playable file`() = runTest {
+ server.enqueue(MockResponse().setResponseCode(500))
+ val ep = episode().also { dao.rows += it }
+
+ val result = downloader.download(ep, SubscriptionKind.PODCAST)
+ assertTrue(result.isFailure)
+
+ val stored = dao.rows.single()
+ assertEquals(DownloadState.FAILED, stored.downloadState)
+ assertNull(stored.localPath)
+ assertEquals("nothing should be left in the library", 0, library.listFiles()!!.size)
+ }
+
+ @Test
+ fun `no partial files are left behind after a failure`() = runTest {
+ server.enqueue(MockResponse().setResponseCode(404))
+ val ep = episode().also { dao.rows += it }
+ downloader.download(ep, SubscriptionKind.PODCAST)
+ assertTrue(library.walkTopDown().none { it.name.endsWith(".part") })
+ }
+
+ @Test
+ fun `an unreachable host fails without throwing`() = runTest {
+ val url = server.url("/a.mp3").toString()
+ server.shutdown()
+ val ep = episode(url = url).also { dao.rows += it }
+ assertTrue(downloader.download(ep, SubscriptionKind.PODCAST).isFailure)
+ assertEquals(DownloadState.FAILED, dao.rows.single().downloadState)
+ }
+
+ /** Re-downloading something already present should be free. */
+ @Test
+ fun `an already-downloaded episode is not fetched again`() = runTest {
+ server.enqueue(MockResponse().setBody("audio-bytes"))
+ val ep = episode().also { dao.rows += it }
+ downloader.download(ep, SubscriptionKind.PODCAST)
+
+ val requestsBefore = server.requestCount
+ downloader.download(dao.rows.single(), SubscriptionKind.PODCAST)
+ assertEquals(requestsBefore, server.requestCount)
+ }
+
+ /** A recorded download whose file has been deleted must re-fetch, not fail to play. */
+ @Test
+ fun `a missing local file is downloaded again`() = runTest {
+ server.enqueue(MockResponse().setBody("audio-bytes"))
+ val ep = episode().also { dao.rows += it }
+ downloader.download(ep, SubscriptionKind.PODCAST)
+ File(dao.rows.single().localPath!!).delete()
+
+ server.enqueue(MockResponse().setBody("audio-bytes"))
+ val result = downloader.download(dao.rows.single(), SubscriptionKind.PODCAST)
+ assertTrue(result.isSuccess)
+ assertTrue(File(dao.rows.single().localPath!!).exists())
+ }
+
+ @Test
+ fun `the file name cannot escape the library directory`() = runTest {
+ server.enqueue(MockResponse().setBody("x"))
+ val ep = episode(id = "../../escape").also { dao.rows += it }
+ downloader.download(ep, SubscriptionKind.PODCAST)
+
+ val path = dao.rows.single().localPath
+ if (path != null) {
+ assertTrue("escaped the library: $path",
+ File(path).canonicalPath.startsWith(library.canonicalPath))
+ }
+ assertFalse(File(library.parentFile, "escape").exists())
+ }
+
+ @Test
+ fun `marks the episode as downloading while in flight`() = runTest {
+ server.enqueue(MockResponse().setBody("audio-bytes"))
+ val ep = episode().also { dao.rows += it }
+ val seen = mutableListOf<DownloadState>()
+ dao.onUpdate = { seen += it.downloadState }
+
+ downloader.download(ep, SubscriptionKind.PODCAST)
+ assertTrue("progress should be visible, saw $seen", seen.contains(DownloadState.DOWNLOADING))
+ }
+
+ /**
+ * A YouTube subscription's media URL is a watch page, not audio. Fetching it directly would
+ * store an HTML document with an .mp3 name that fails to play with no clue why.
+ */
+ @Test
+ fun `a youtube episode is not fetched over plain http`() = runTest {
+ val ep = episode(url = "https://www.youtube.com/watch?v=abc").also { dao.rows += it }
+ downloader.download(ep, SubscriptionKind.YOUTUBE_CHANNEL)
+ assertEquals("no plain HTTP request should have been made", 0, server.requestCount)
+ }
+}