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:
Tizian 「ティツィアーン」 2026-10-29 18:57:19 +01:00
commit 409a2519f9
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
5 changed files with 830 additions and 207 deletions

View file

@ -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) {

View file

@ -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

View file

@ -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()
}
}

View file

@ -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()
}
}

View file

@ -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())
}
}