feat(train): the marked playlists downloaded by a WorkManager job, the app open or not (ADR 0030, D52)
The app reads the playlists as before and hands the missing tracks to MusicWorker.kt, which runs every 6 hours and once whenever the list changes, on unmetered networks with the battery and storage not low. MusicFetcher.kt downloads each with the token from JellyfinTokens, works out its tempo and appends a line to fetched.txt, which the app takes in as it opens and as each track comes. The app's own download is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
6bde74f0ba
commit
409a2519f9
5 changed files with 830 additions and 207 deletions
|
|
@ -119,6 +119,7 @@ class MainActivity : FlutterActivity() {
|
|||
MethodChannel(messenger, "app.halkyon.train/files").setMethodCallHandler(files)
|
||||
EventChannel(messenger, "app.halkyon.train/files/opened").setStreamHandler(files.opened)
|
||||
MethodChannel(messenger, "app.halkyon.train/music").setMethodCallHandler(music)
|
||||
EventChannel(messenger, "app.halkyon.train/music/fetched").setStreamHandler(music.fetched)
|
||||
EventChannel(messenger, "app.halkyon.train/recorder/lines").setStreamHandler(
|
||||
object : EventChannel.StreamHandler {
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
|
||||
|
|
|
|||
|
|
@ -6,25 +6,21 @@ import android.media.MediaCodec
|
|||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import android.net.ConnectivityManager
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.io.File
|
||||
import java.nio.ByteOrder
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* Music's side (ADR 0030, D47), over `app.halkyon.train/music`: whether the network is metered,
|
||||
* as music is downloaded on unmetered ones only; a downloaded track's tempo, the tag its
|
||||
* file names, else a minute from its middle decoded by Android's own decoder, mixed down to
|
||||
* mono and handed to [Tempo], one track at a time, off the main thread; and the person's
|
||||
* Jellyfin token, in [JellyfinTokens] (D52).
|
||||
* mono and handed to [Tempo], for [MusicWorker]; the person's Jellyfin token, in
|
||||
* [JellyfinTokens] (D52); the music job's list ([MusicWorker.schedule]), and each track it
|
||||
* fetched, over `…/music/fetched`.
|
||||
*/
|
||||
class MusicBridge(private val context: Context) : MethodChannel.MethodCallHandler {
|
||||
private val worker = Executors.newSingleThreadExecutor()
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
private val tokens by lazy { JellyfinTokens.of(context) }
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
|
|
@ -33,19 +29,9 @@ class MusicBridge(private val context: Context) : MethodChannel.MethodCallHandle
|
|||
val connectivity = context.getSystemService(ConnectivityManager::class.java)
|
||||
result.success(connectivity.activeNetwork != null && !connectivity.isActiveNetworkMetered)
|
||||
}
|
||||
"tempo" -> {
|
||||
val path = call.argument<String>("path")!!
|
||||
worker.execute {
|
||||
val tempo =
|
||||
try {
|
||||
of(File(path))
|
||||
} catch (error: Exception) {
|
||||
// Its kind only: never the track's name.
|
||||
Log.w("Music", "Tempo not worked out: ${error.javaClass.simpleName}")
|
||||
null
|
||||
}
|
||||
main.post { result.success(tempo) }
|
||||
}
|
||||
"schedule" -> {
|
||||
MusicWorker.schedule(context, call.argument<String>("job"))
|
||||
result.success(null)
|
||||
}
|
||||
"tokenRead" -> result.success(tokens.read(call.argument<String>("person")!!))
|
||||
"tokenKeep" -> {
|
||||
|
|
@ -60,7 +46,21 @@ class MusicBridge(private val context: Context) : MethodChannel.MethodCallHandle
|
|||
}
|
||||
}
|
||||
|
||||
fun close() = worker.shutdownNow()
|
||||
/** Each track the music job fetched, while the app listens. */
|
||||
val fetched =
|
||||
object : EventChannel.StreamHandler {
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
|
||||
MusicWorker.fetched = { events.success(null) }
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {
|
||||
MusicWorker.fetched = null
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
MusicWorker.fetched = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val SECONDS = 60
|
||||
|
|
|
|||
|
|
@ -0,0 +1,105 @@
|
|||
package app.halkyon.train
|
||||
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
/**
|
||||
* Downloads the tracks the app listed (ADR 0030, D52), for the music job: each from Jellyfin
|
||||
* into the person's music folder, through a part file renamed once whole, its tempo worked out
|
||||
* where asked, and a line of JSON each appended to the folder's [FILE] (`id`, `bytes`, `tempo`,
|
||||
* `analysed`), which the app takes in. What a line names already is skipped, as the app may not
|
||||
* have taken it in yet. Never logs a track's name.
|
||||
*/
|
||||
class MusicFetcher(
|
||||
private val token: (String) -> String?,
|
||||
private val tempo: (File) -> Double?,
|
||||
private val stopped: () -> Boolean = { false },
|
||||
private val fetched: () -> Unit = {},
|
||||
) {
|
||||
/** What the app wants: the person, their music folder, the server and what to fetch from it. */
|
||||
class Job(
|
||||
val person: String,
|
||||
val folder: File,
|
||||
val address: String,
|
||||
/** Jellyfin's `Authorization` header as the app makes it, without the token. */
|
||||
val client: String,
|
||||
val tracks: List<Track>,
|
||||
)
|
||||
|
||||
/** A track to fetch: its ID, the path and query under the server's address, and whether its tempo is wanted. */
|
||||
class Track(val id: String, val path: String, val analyse: Boolean = true)
|
||||
|
||||
enum class Outcome {
|
||||
/** Everything fetched, or skipped for its own error. */
|
||||
DONE,
|
||||
|
||||
/** Jellyfin refused the token: nothing more until the app signs in again. */
|
||||
REFUSED,
|
||||
|
||||
/** The server out of reach: tried again later. */
|
||||
UNREACHABLE,
|
||||
|
||||
/** Stopped by WorkManager, its constraints gone. */
|
||||
STOPPED,
|
||||
}
|
||||
|
||||
fun fetch(job: Job): Outcome {
|
||||
val token = token(job.person) ?: return Outcome.REFUSED
|
||||
val lines = File(job.folder, FILE)
|
||||
val done = fetchedIds(lines)
|
||||
for (track in job.tracks) {
|
||||
if (stopped()) return Outcome.STOPPED
|
||||
if (track.id in done || !ID.matches(track.id) || !track.path.startsWith("/Audio/")) continue
|
||||
val file = File(job.folder, track.id)
|
||||
val part = File(job.folder, "${track.id}.part")
|
||||
val connection = URL(job.address.trimEnd('/') + track.path).openConnection() as HttpURLConnection
|
||||
try {
|
||||
connection.connectTimeout = TIMEOUT_MS
|
||||
connection.readTimeout = TIMEOUT_MS
|
||||
connection.setRequestProperty("Authorization", "${job.client}, Token=\"$token\"")
|
||||
val status = connection.responseCode
|
||||
if (status == 401 || status == 403) return Outcome.REFUSED
|
||||
if (status >= 300) continue
|
||||
job.folder.mkdirs()
|
||||
connection.inputStream.use { input -> FileOutputStream(part).use { input.copyTo(it) } }
|
||||
if (stopped()) {
|
||||
part.delete()
|
||||
return Outcome.STOPPED
|
||||
}
|
||||
if (!part.renameTo(file)) continue
|
||||
} catch (error: IOException) {
|
||||
part.delete()
|
||||
return Outcome.UNREACHABLE
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
val beats = if (track.analyse) tempo(file) else null
|
||||
val line =
|
||||
mutableMapOf<String, Any?>("id" to track.id, "bytes" to file.length())
|
||||
.apply { if (track.analyse) putAll(mapOf("tempo" to beats, "analysed" to true)) }
|
||||
FileOutputStream(lines, true).use { out ->
|
||||
out.write((JsonText.of(line) + "\n").toByteArray())
|
||||
out.fd.sync()
|
||||
}
|
||||
done += track.id
|
||||
fetched()
|
||||
}
|
||||
return Outcome.DONE
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** The lines of what was fetched, in the person's music folder. */
|
||||
const val FILE = "fetched.txt"
|
||||
|
||||
private const val TIMEOUT_MS = 30_000
|
||||
private val ID = Regex("[A-Za-z0-9-]+")
|
||||
private val LINE_ID = Regex("^\\{\"id\":\"([A-Za-z0-9-]+)\"")
|
||||
|
||||
private fun fetchedIds(lines: File): MutableSet<String> =
|
||||
if (!lines.exists()) mutableSetOf()
|
||||
else lines.readLines().mapNotNull { LINE_ID.find(it)?.groupValues?.get(1) }.toMutableSet()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
package app.halkyon.train
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Downloads the marked playlists' tracks (ADR 0030, D52), the open app's and while it is closed:
|
||||
* the tracks the app last listed as missing ([schedule]), every 6 hours and once whenever the list
|
||||
* changes, on an unmetered network with the battery and the storage not low. [MusicFetcher]
|
||||
* fetches them, one run at a time in the process, and runs again while the list changed under it;
|
||||
* the open app hears of each track through [fetched].
|
||||
*/
|
||||
class MusicWorker(context: Context, parameters: WorkerParameters) : CoroutineWorker(context, parameters) {
|
||||
override suspend fun doWork(): Result =
|
||||
lock.withLock {
|
||||
withContext(Dispatchers.IO) {
|
||||
val tokens = JellyfinTokens.of(applicationContext)
|
||||
val fetcher =
|
||||
MusicFetcher(
|
||||
token = tokens::read,
|
||||
tempo = { file ->
|
||||
try {
|
||||
MusicBridge.of(file)
|
||||
} catch (error: Exception) {
|
||||
Log.w("Music", "Tempo not worked out: ${error.javaClass.simpleName}")
|
||||
null
|
||||
}
|
||||
},
|
||||
stopped = { isStopped },
|
||||
fetched = { main.post { fetched?.invoke() } },
|
||||
)
|
||||
var read: String? = null
|
||||
while (true) {
|
||||
val text = configFile(applicationContext).takeIf { it.exists() }?.readText() ?: break
|
||||
if (text == read) break
|
||||
read = text
|
||||
val job = jobOf(text) ?: break
|
||||
when (fetcher.fetch(job)) {
|
||||
MusicFetcher.Outcome.UNREACHABLE -> return@withContext Result.retry()
|
||||
MusicFetcher.Outcome.DONE -> continue
|
||||
else -> break
|
||||
}
|
||||
}
|
||||
Result.success()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CONFIG = "music_background.json"
|
||||
private const val PERIODIC = "music-tracks"
|
||||
private const val NOW = "music-tracks-now"
|
||||
private val lock = Mutex()
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
|
||||
/** Called on the main thread after each track fetched, while the app listens. */
|
||||
@Volatile var fetched: (() -> Unit)? = null
|
||||
|
||||
/** Fetches the tracks [config] lists (JSON: `person`, `folder`, `address`, `client`, `tracks`), or none where it is null. */
|
||||
fun schedule(context: Context, config: String?) {
|
||||
val work = WorkManager.getInstance(context)
|
||||
val file = configFile(context)
|
||||
if (config == null) {
|
||||
file.delete()
|
||||
work.cancelUniqueWork(PERIODIC)
|
||||
work.cancelUniqueWork(NOW)
|
||||
return
|
||||
}
|
||||
val part = File(file.path + ".part")
|
||||
part.writeText(config)
|
||||
part.renameTo(file)
|
||||
val constraints =
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.UNMETERED)
|
||||
.setRequiresBatteryNotLow(true)
|
||||
.setRequiresStorageNotLow(true)
|
||||
.build()
|
||||
work.enqueueUniquePeriodicWork(
|
||||
PERIODIC,
|
||||
ExistingPeriodicWorkPolicy.KEEP,
|
||||
PeriodicWorkRequestBuilder<MusicWorker>(6, TimeUnit.HOURS).setConstraints(constraints).build(),
|
||||
)
|
||||
// A run under way reads the new list as it ends; one waiting reads it as it starts.
|
||||
work.enqueueUniqueWork(
|
||||
NOW,
|
||||
ExistingWorkPolicy.KEEP,
|
||||
OneTimeWorkRequestBuilder<MusicWorker>().setConstraints(constraints).build(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun configFile(context: Context) = File(context.noBackupFilesDir, CONFIG)
|
||||
|
||||
private fun jobOf(text: String): MusicFetcher.Job? =
|
||||
runCatching {
|
||||
val json = JSONObject(text)
|
||||
val tracks = json.getJSONArray("tracks")
|
||||
MusicFetcher.Job(
|
||||
person = json.getString("person"),
|
||||
folder = File(json.getString("folder")),
|
||||
address = json.getString("address"),
|
||||
client = json.getString("client"),
|
||||
tracks =
|
||||
(0 until tracks.length()).map {
|
||||
val track = tracks.getJSONObject(it)
|
||||
MusicFetcher.Track(track.getString("id"), track.getString("path"), track.optBoolean("analyse", true))
|
||||
},
|
||||
)
|
||||
}
|
||||
.onFailure { Log.w("Music", "The music job's list not read: ${it.javaClass.simpleName}") }
|
||||
.getOrNull()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
package app.halkyon.train
|
||||
|
||||
import java.io.File
|
||||
import java.net.InetAddress
|
||||
import java.net.ServerSocket
|
||||
import java.net.Socket
|
||||
import java.nio.file.Files
|
||||
import kotlin.concurrent.thread
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
// The music job's downloads (ADR 0030, D52), from a Jellyfin of the test's
|
||||
// own: each track the app listed into the folder, a line each, its tempo
|
||||
// where asked; refused, unreachable and stopped as WorkManager needs them.
|
||||
class MusicFetcherTest {
|
||||
private val folder: File = Files.createTempDirectory("music").toFile()
|
||||
private val asked = mutableListOf<String>()
|
||||
private val authorizations = mutableListOf<String>()
|
||||
private val broken = mutableSetOf<String>()
|
||||
private var valid = "token-1"
|
||||
// Android's stubs have no HTTP server: one request a connection, enough for HttpURLConnection.
|
||||
private val server = ServerSocket(0, 0, InetAddress.getByName("127.0.0.1"))
|
||||
private val serving =
|
||||
thread(isDaemon = true) {
|
||||
while (!server.isClosed) {
|
||||
val socket = runCatching { server.accept() }.getOrNull() ?: break
|
||||
socket.use { answer(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun answer(socket: Socket) {
|
||||
val reader = socket.getInputStream().bufferedReader()
|
||||
val target = reader.readLine()?.split(' ')?.getOrNull(1) ?: return
|
||||
var authorization = ""
|
||||
while (true) {
|
||||
val header = reader.readLine() ?: break
|
||||
if (header.isEmpty()) break
|
||||
if (header.startsWith("Authorization:", ignoreCase = true)) authorization = header.substringAfter(':').trim()
|
||||
}
|
||||
authorizations += authorization
|
||||
asked += target
|
||||
val id = target.substringBefore('?').split('/')[3]
|
||||
val (status, body) =
|
||||
when {
|
||||
!authorization.endsWith("Token=\"$valid\"") -> 401 to ""
|
||||
id in broken -> 500 to ""
|
||||
else -> 200 to "audio of $id"
|
||||
}
|
||||
val bytes = body.toByteArray()
|
||||
val out = socket.getOutputStream()
|
||||
out.write("HTTP/1.1 $status X\r\nContent-Length: ${bytes.size}\r\nConnection: close\r\n\r\n".toByteArray())
|
||||
out.write(bytes)
|
||||
out.flush()
|
||||
}
|
||||
|
||||
private val address = "http://127.0.0.1:${server.localPort}/jellyfin"
|
||||
private val fetchedCount = intArrayOf(0)
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
server.close()
|
||||
folder.deleteRecursively()
|
||||
}
|
||||
|
||||
private fun fetcher(token: String? = "token-1", stopAfter: Int = Int.MAX_VALUE) =
|
||||
MusicFetcher(
|
||||
token = { if (it == "ada") token else null },
|
||||
tempo = { file -> if (file.name == "t1") 128.0 else null },
|
||||
stopped = { fetchedCount[0] >= stopAfter },
|
||||
fetched = { fetchedCount[0]++ },
|
||||
)
|
||||
|
||||
private fun job(vararg ids: String, analyse: Boolean = true) =
|
||||
MusicFetcher.Job(
|
||||
person = "ada",
|
||||
folder = folder,
|
||||
address = address,
|
||||
client = "MediaBrowser Client=\"Halkyon Train\", DeviceId=\"d1\"",
|
||||
tracks = ids.map { MusicFetcher.Track(it, "/Audio/$it/stream?static=true", analyse) },
|
||||
)
|
||||
|
||||
private fun lines() = File(folder, MusicFetcher.FILE).readLines()
|
||||
|
||||
@Test
|
||||
fun `each track listed is fetched into the folder, a line each with its tempo`() {
|
||||
assertEquals(MusicFetcher.Outcome.DONE, fetcher().fetch(job("t1", "t2")))
|
||||
|
||||
assertEquals("audio of t1", File(folder, "t1").readText())
|
||||
assertEquals(
|
||||
listOf(
|
||||
"""{"id":"t1","bytes":11,"tempo":128.0,"analysed":true}""",
|
||||
"""{"id":"t2","bytes":11,"tempo":null,"analysed":true}""",
|
||||
),
|
||||
lines(),
|
||||
)
|
||||
assertEquals(listOf("/jellyfin/Audio/t1/stream?static=true", "/jellyfin/Audio/t2/stream?static=true"), asked)
|
||||
assertEquals("MediaBrowser Client=\"Halkyon Train\", DeviceId=\"d1\", Token=\"token-1\"", authorizations[0])
|
||||
assertEquals(2, fetchedCount[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `what a line names already is not fetched again`() {
|
||||
fetcher().fetch(job("t1"))
|
||||
fetcher().fetch(job("t1", "t2"))
|
||||
|
||||
assertEquals(2, asked.size)
|
||||
assertEquals(2, lines().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a track whose tempo is known is fetched without working it out`() {
|
||||
fetcher().fetch(job("t1", analyse = false))
|
||||
|
||||
assertEquals(listOf("""{"id":"t1","bytes":11}"""), lines())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a track the server fails is skipped, the others fetched, no part file left`() {
|
||||
broken += "t1"
|
||||
assertEquals(MusicFetcher.Outcome.DONE, fetcher().fetch(job("t1", "t2")))
|
||||
|
||||
assertFalse(File(folder, "t1").exists())
|
||||
assertTrue(File(folder, "t2").exists())
|
||||
assertTrue(folder.list()!!.none { it.endsWith(".part") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a token refused, or none, stops everything`() {
|
||||
valid = "another"
|
||||
assertEquals(MusicFetcher.Outcome.REFUSED, fetcher().fetch(job("t1", "t2")))
|
||||
assertEquals(1, asked.size)
|
||||
assertEquals(MusicFetcher.Outcome.REFUSED, fetcher(token = null).fetch(job("t1")))
|
||||
assertEquals(1, asked.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a server out of reach is tried again later`() {
|
||||
server.close()
|
||||
serving.join()
|
||||
assertEquals(MusicFetcher.Outcome.UNREACHABLE, fetcher().fetch(job("t1")))
|
||||
assertFalse(File(folder, "t1").exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stopped, it leaves the rest for the next run`() {
|
||||
assertEquals(MusicFetcher.Outcome.STOPPED, fetcher(stopAfter = 1).fetch(job("t1", "t2")))
|
||||
assertEquals(1, lines().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an ID or a path that could reach elsewhere is never fetched`() {
|
||||
val job =
|
||||
MusicFetcher.Job(
|
||||
person = "ada",
|
||||
folder = folder,
|
||||
address = address,
|
||||
client = "MediaBrowser",
|
||||
tracks = listOf(MusicFetcher.Track("../escape", "/Audio/x/stream"), MusicFetcher.Track("t1", "/Users/x")),
|
||||
)
|
||||
fetcher().fetch(job)
|
||||
|
||||
assertTrue(asked.isEmpty())
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue