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
15 changed files with 830 additions and 207 deletions

File diff suppressed because one or more lines are too long

View file

@ -119,6 +119,7 @@ class MainActivity : FlutterActivity() {
MethodChannel(messenger, "app.halkyon.train/files").setMethodCallHandler(files) MethodChannel(messenger, "app.halkyon.train/files").setMethodCallHandler(files)
EventChannel(messenger, "app.halkyon.train/files/opened").setStreamHandler(files.opened) EventChannel(messenger, "app.halkyon.train/files/opened").setStreamHandler(files.opened)
MethodChannel(messenger, "app.halkyon.train/music").setMethodCallHandler(music) 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( EventChannel(messenger, "app.halkyon.train/recorder/lines").setStreamHandler(
object : EventChannel.StreamHandler { object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink) { 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.MediaExtractor
import android.media.MediaFormat import android.media.MediaFormat
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.os.Handler import io.flutter.plugin.common.EventChannel
import android.os.Looper
import android.util.Log
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import java.io.File import java.io.File
import java.nio.ByteOrder 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, * 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 * 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 * 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 * mono and handed to [Tempo], for [MusicWorker]; the person's Jellyfin token, in
* Jellyfin token, in [JellyfinTokens] (D52). * [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 { 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) } private val tokens by lazy { JellyfinTokens.of(context) }
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { 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) val connectivity = context.getSystemService(ConnectivityManager::class.java)
result.success(connectivity.activeNetwork != null && !connectivity.isActiveNetworkMetered) result.success(connectivity.activeNetwork != null && !connectivity.isActiveNetworkMetered)
} }
"tempo" -> { "schedule" -> {
val path = call.argument<String>("path")!! MusicWorker.schedule(context, call.argument<String>("job"))
worker.execute { result.success(null)
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) }
}
} }
"tokenRead" -> result.success(tokens.read(call.argument<String>("person")!!)) "tokenRead" -> result.success(tokens.read(call.argument<String>("person")!!))
"tokenKeep" -> { "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 { companion object {
private const val SECONDS = 60 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())
}
}

View file

@ -436,6 +436,7 @@ Future<TrainPerson> _openPersonData(
music, music,
Directory(p.join(folder.path, 'music')), Directory(p.join(folder.path, 'music')),
AndroidMusicPlatform(), AndroidMusicPlatform(),
person: person.authUserId,
)..open()); )..open());
final resumed = keptMusic == null final resumed = keptMusic == null
? null ? null

View file

@ -264,50 +264,13 @@ class JellyfinClient(
]; ];
} }
/// Downloads the track [id] as Jellyfin keeps it into [file], through a /// The path and query under the server's address the track [id] is
/// part file renamed once whole; its size. /// downloaded from, as Jellyfin keeps it (D47, D52).
Future<int> download( static String audioPath(String id) => '/Audio/$id/stream?static=true';
Uri address,
JellyfinSession session, /// The `Authorization` header Train asks with, without a token: the
String id, /// music job adds the token it keeps (D52).
File file, String get clientAuthorization => _authorization(null);
) async {
final request = http.Request(
'GET',
address.replace(
path: '${address.path}/Audio/$id/stream',
queryParameters: {'static': 'true'},
),
)..headers['authorization'] = _authorization(session.token);
final part = File('${file.path}.part');
try {
final response = await _http.send(request).timeout(_timeout);
if (response.statusCode == 401 || response.statusCode == 403) {
throw const JellyfinException(JellyfinFailure.refused);
}
if (response.statusCode >= 300) {
throw const JellyfinException(JellyfinFailure.failed);
}
file.parent.createSync(recursive: true);
final sink = part.openWrite();
try {
// A connection that stalls for the timeout gives up.
await sink.addStream(response.stream.timeout(_timeout));
} finally {
await sink.close();
}
part.renameSync(file.path);
return file.lengthSync();
} on Object catch (error) {
if (part.existsSync()) {
part.deleteSync();
}
if (_unreachable(error)) {
throw const JellyfinException(JellyfinFailure.unreachable);
}
rethrow;
}
}
/// Ends [session]'s token on the server; what fails is left, as the /// Ends [session]'s token on the server; what fails is left, as the
/// device forgets the token anyway. /// device forgets the token anyway.

View file

@ -5,7 +5,6 @@ import 'dart:io';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:halkyon_core/halkyon_core.dart'; import 'package:halkyon_core/halkyon_core.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/jellyfin_client.dart'; import 'package:halkyon_train_flutter/src/feature/music/data/jellyfin_client.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/music_library.dart'; import 'package:halkyon_train_flutter/src/feature/music/data/music_library.dart';
@ -18,7 +17,8 @@ final class const KeptMusic({
/// Every track of the marked playlists, by its ID, downloaded or not. /// Every track of the marked playlists, by its ID, downloaded or not.
final Map<String, MusicTrack> tracks = const {}, final Map<String, MusicTrack> tracks = const {},
/// Whether a download is under way. /// Whether the playlists are being read, or the music job fetches what
/// they hold.
final bool loading = false, final bool loading = false,
/// Whether downloads wait for an unmetered network. /// Whether downloads wait for an unmetered network.
@ -48,16 +48,22 @@ final class const KeptMusic({
List<Object?> get props => [tracks, loading, waiting]; List<Object?> get props => [tracks, loading, waiting];
} }
/// Keeps the marked playlists' tracks on this phone (D47): their list read /// Keeps the marked playlists' tracks on this phone (D47, D52): their list
/// from Jellyfin, each track downloaded as Jellyfin keeps it on unmetered /// read from Jellyfin as the app opens and resumes, the missing ones handed
/// networks only, its tempo worked out once ([MusicPlatform.tempo]); the /// to the music job ([MusicPlatform.schedule]), which downloads them as
/// tracks no marked playlist holds any more deleted, offline too. Files in /// Jellyfin keeps them on unmetered networks, the app open or not, and
/// [_folder] by their IDs, the list in its `tracks.json`. /// works out their tempo once; what it fetched taken in from [_folder]'s
/// `fetched.txt` as the app opens and as each comes. The tracks no marked
/// playlist holds any more are deleted, offline too. Files in [_folder] by
/// their IDs, the list in its `tracks.json`.
class MusicDownloads( class MusicDownloads(
final MusicLibrary _library, final MusicLibrary _library,
final Directory _folder, final Directory _folder,
final MusicPlatform _platform, final MusicPlatform _platform, {
) {
/// Whose music it is, as the job finds their token by it.
required final String person,
}) {
late final ValueNotifier<KeptMusic> kept = ValueNotifier( late final ValueNotifier<KeptMusic> kept = ValueNotifier(
KeptMusic(tracks: _read()), KeptMusic(tracks: _read()),
); );
@ -69,6 +75,7 @@ class MusicDownloads(
); );
var _closed = false; var _closed = false;
StreamSubscription<void>? _fetched;
File get _list => File(p.join(_folder.path, 'tracks.json')); File get _list => File(p.join(_folder.path, 'tracks.json'));
@ -81,6 +88,7 @@ class MusicDownloads(
/// whenever a network may have come (the app resumed). /// whenever a network may have come (the app resumed).
void open() { void open() {
_library.settings.addListener(_marksChanged); _library.settings.addListener(_marksChanged);
_fetched = _platform.fetched.listen((_) => _takeIn());
unawaited(sync()); unawaited(sync());
} }
@ -90,6 +98,7 @@ class MusicDownloads(
Future<void> dispose() async { Future<void> dispose() async {
_closed = true; _closed = true;
_library.settings.removeListener(_marksChanged); _library.settings.removeListener(_marksChanged);
await _fetched?.cancel();
kept.dispose(); kept.dispose();
} }
@ -103,19 +112,30 @@ class MusicDownloads(
} }
Future<void> _sync() async { Future<void> _sync() async {
_takeIn();
final settings = _library.settings.value; final settings = _library.settings.value;
_marked = {...settings.playlists.keys}; _marked = {...settings.playlists.keys};
// What no marked playlist holds goes first, with or without a network. // What no marked playlist holds goes first, with or without a network.
_keepOnly((track) => track.playlists.any(_marked.contains)); _keepOnly((track) => track.playlists.any(_marked.contains));
if (!settings.signedIn || _marked.isEmpty || _closed) { if (!settings.signedIn || _marked.isEmpty || _closed) {
await _schedule();
return; return;
} }
if (!await _platform.unmetered()) { if (!await _platform.unmetered()) {
_set(kept.value.copyWith(waiting: true)); await _schedule();
return; return;
} }
_set(kept.value.copyWith(waiting: false, loading: true)); _set(kept.value.copyWith(loading: true));
try { try {
await _readPlaylists();
} finally {
await _schedule();
}
}
/// Reads the marked playlists, keeping what a playlist that can't be
/// read had.
Future<void> _readPlaylists() async {
final listed = <String, MusicTrack>{}; final listed = <String, MusicTrack>{};
final unread = <String>{}; final unread = <String>{};
for (final playlist in _marked) { for (final playlist in _marked) {
@ -157,58 +177,99 @@ class MusicDownloads(
// forgets them. // forgets them.
_keepOnly((track) => listed.containsKey(track.id)); _keepOnly((track) => listed.containsKey(track.id));
_set(kept.value.copyWith(tracks: listed)); _set(kept.value.copyWith(tracks: listed));
await _download();
} finally {
if (!_closed) {
_set(kept.value.copyWith(loading: false));
}
}
} }
Future<void> _download() async { /// The missing tracks the job is to fetch, or none where no one is
for (final id in [...kept.value.tracks.keys]) { /// signed in or nothing is missing.
final track = kept.value.tracks[id]; List<MusicJobTrack> get _missing => [
final file = fileOf(id); for (final track in kept.value.tracks.values)
if (track == null || file == null || _closed) { if (!track.downloaded || !track.analysed)
continue; (
} id: track.id,
if (!track.downloaded) { path: JellyfinClient.audioPath(track.id),
if (!await _platform.unmetered()) { analyse: !track.analysed,
_set(kept.value.copyWith(waiting: true)); ),
return; ];
}
final int bytes; /// Hands the job the missing tracks, and says whether they wait for an
try { /// unmetered network.
bytes = await _library.download(id, file); Future<void> _schedule() async {
} on JellyfinException catch (error) {
if (error.failure == JellyfinFailure.failed) {
// This track alone: the next sync tries it again.
continue;
}
return;
}
if (_closed) { if (_closed) {
return; return;
} }
_update(id, (track) => track.copyWith(bytes: () => bytes)); final settings = _library.settings.value;
final missing = _missing;
final address = settings.address;
if (!settings.signedIn || address == null || missing.isEmpty) {
await _platform.schedule(null);
_set(kept.value.copyWith(loading: false, waiting: false));
return;
} }
if (!track.analysed) { await _platform.schedule(
double? tempo; MusicJob(
try { person: person,
tempo = await _platform.tempo(file.path); folder: _folder.path,
} on PlatformException catch (error) { address: address,
log('Tempo not worked out: ${error.code}', name: 'Music'); client: _library.jellyfin.clientAuthorization,
tracks: missing,
),
);
final unmetered = await _platform.unmetered();
_set(kept.value.copyWith(loading: unmetered, waiting: !unmetered));
} }
/// Takes in what the job fetched: each line of `fetched.txt` for a track
/// still kept, the file of one no longer kept deleted.
void _takeIn() {
if (_closed) { if (_closed) {
return; return;
} }
final lines = File(p.join(_folder.path, 'fetched.txt'));
final taking = File(p.join(_folder.path, 'fetched.taking'));
try {
if (lines.existsSync() && !taking.existsSync()) {
lines.renameSync(taking.path);
}
if (!taking.existsSync()) {
return;
}
for (final line in taking.readAsLinesSync()) {
final Object? json;
try {
json = jsonDecode(line);
} on FormatException {
continue;
}
if (json
case {
'id': final String id,
'bytes': final int bytes,
} &&
final Map<String, Object?> fetched) {
if (kept.value.tracks.containsKey(id)) {
_update( _update(
id, id,
(track) => track.copyWith(tempo: () => tempo, analysed: true), (track) => fetched['analysed'] == true
? track.copyWith(
bytes: () => bytes,
tempo: () => (fetched['tempo'] as num?)?.toDouble(),
analysed: true,
)
: track.copyWith(bytes: () => bytes),
); );
} else {
_deleteFile(id);
} }
} }
} }
taking.deleteSync();
} on FileSystemException catch (error) {
log('Fetched music not taken in: ${error.runtimeType}', name: 'Music');
}
if (_missing.isEmpty && kept.value.loading) {
_set(kept.value.copyWith(loading: false));
}
}
void _update(String id, MusicTrack Function(MusicTrack) change) { void _update(String id, MusicTrack Function(MusicTrack) change) {
if (kept.value.tracks[id] case final track?) { if (kept.value.tracks[id] case final track?) {
@ -228,13 +289,7 @@ class MusicDownloads(
if (gone.isEmpty) { if (gone.isEmpty) {
return; return;
} }
for (final id in gone) { gone.forEach(_deleteFile);
try {
fileOf(id)?.deleteSync();
} on PathNotFoundException {
// Never downloaded.
}
}
_set( _set(
kept.value.copyWith( kept.value.copyWith(
tracks: { tracks: {
@ -245,6 +300,14 @@ class MusicDownloads(
); );
} }
void _deleteFile(String id) {
try {
fileOf(id)?.deleteSync();
} on PathNotFoundException {
// Never downloaded.
}
}
Map<String, MusicTrack> _read() { Map<String, MusicTrack> _read() {
try { try {
final json = jsonDecode(_list.readAsStringSync()); final json = jsonDecode(_list.readAsStringSync());

View file

@ -1,5 +1,3 @@
import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/jellyfin_client.dart'; import 'package:halkyon_train_flutter/src/feature/music/data/jellyfin_client.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/music_store.dart'; import 'package:halkyon_train_flutter/src/feature/music/data/music_store.dart';
@ -70,11 +68,6 @@ class MusicLibrary(
(address, session) => jellyfin.tracks(address, session, playlist), (address, session) => jellyfin.tracks(address, session, playlist),
); );
/// Downloads the track [id] into [file]; its size.
Future<int> download(String id, File file) => _asking(
(address, session) => jellyfin.download(address, session, id, file),
);
/// What [ask] answers as the person signed in. /// What [ask] answers as the person signed in.
Future<T> _asking<T>( Future<T> _asking<T>(
Future<T> Function(Uri address, JellyfinSession session) ask, Future<T> Function(Uri address, JellyfinSession session) ask,

View file

@ -1,26 +1,74 @@
import 'dart:convert';
import 'package:equatable/equatable.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
/// The phone's side of music (ADR 0030, D47). /// A track for the music job to fetch (ADR 0030, D52): its ID, the path and
/// query under the server's address, and whether its tempo is wanted.
typedef MusicJobTrack = ({String id, String path, bool analyse});
/// What the music job fetches (D52): the person's missing tracks from their
/// server into their music folder, signed in with the token Train keeps for
/// them.
final class const MusicJob({
required final String person,
required final String folder,
required final Uri address,
/// Jellyfin's `Authorization` header as Train makes it, without the
/// token, which the job adds.
required final String client,
required final List<MusicJobTrack> tracks,
}) with Equatable {
Map<String, Object?> toJson() => {
'person': person,
'folder': folder,
'address': address.toString(),
'client': client,
'tracks': [
for (final track in tracks)
{'id': track.id, 'path': track.path, 'analyse': track.analyse},
],
};
@override
List<Object?> get props => [person, folder, address, client, tracks];
}
/// The phone's side of music (ADR 0030, D47, D52).
abstract interface class MusicPlatform() { abstract interface class MusicPlatform() {
/// Whether the network is unmetered, as downloads wait for one. /// Whether the network is unmetered, as downloads wait for one.
Future<bool> unmetered(); Future<bool> unmetered();
/// The beats per minute of the track in the file at [path]; null where /// Has the music job fetch [job]'s tracks, now and every few hours while
/// neither its tag nor its beat says one. /// some are missing; nothing more where it is null.
Future<double?> tempo(String path); Future<void> schedule(MusicJob? job);
/// Each track the job fetched, while the app is open: a line more in the
/// music folder's `fetched.txt`.
Stream<void> get fetched;
} }
/// [MusicPlatform] on Android, over `app.halkyon.train/music` /// [MusicPlatform] on Android, over `app.halkyon.train/music` and
/// (`MusicBridge.kt`): the tag `BpmTag.kt` reads, else `Tempo.kt`'s own /// `…/music/fetched` (`MusicBridge.kt`, `MusicWorker.kt`: a WorkManager job
/// analysis of a minute from the track's middle. /// on unmetered networks, its tempo the tag `BpmTag.kt` reads, else
/// `Tempo.kt`'s own analysis of a minute from the track's middle).
class AndroidMusicPlatform({ class AndroidMusicPlatform({
final MethodChannel _channel = const MethodChannel('app.halkyon.train/music'), final MethodChannel _channel = const MethodChannel('app.halkyon.train/music'),
final EventChannel _fetched = const EventChannel(
'app.halkyon.train/music/fetched',
),
}) implements MusicPlatform { }) implements MusicPlatform {
@override @override
Future<bool> unmetered() async => Future<bool> unmetered() async =>
await _channel.invokeMethod<bool>('unmetered') ?? false; await _channel.invokeMethod<bool>('unmetered') ?? false;
@override @override
Future<double?> tempo(String path) => Future<void> schedule(MusicJob? job) => _channel.invokeMethod<void>(
_channel.invokeMethod<double>('tempo', {'path': path}); 'schedule',
{'job': job == null ? null : jsonEncode(job.toJson())},
);
@override
Stream<void> get fetched => _fetched.receiveBroadcastStream();
} }

View file

@ -8,23 +8,27 @@ import 'package:halkyon_train_flutter/src/feature/music/data/music_store.dart';
import '../../support/fake_jellyfin.dart'; import '../../support/fake_jellyfin.dart';
/// The marked playlists kept on this phone (ADR 0030, D47): downloaded on /// The marked playlists kept on this phone (ADR 0030, D47, D52): the
/// unmetered networks only, each track once, its tempo worked out once, /// missing tracks handed to the music job, which downloads them on
/// and what no marked playlist holds deleted, offline too. /// unmetered networks only, each track once, its tempo worked out once;
/// what it fetched taken in; what no marked playlist holds deleted, offline
/// too.
void main() { void main() {
late Directory folder; late Directory folder;
late FakeJellyfin jellyfin; late FakeJellyfin jellyfin;
late FakeMusicPlatform platform; late FakeMusicPlatform platform;
late MusicLibrary library; late MusicLibrary library;
late MemoryJellyfinTokenStore tokens;
late MusicDownloads downloads; late MusicDownloads downloads;
setUp(() async { setUp(() async {
folder = Directory.systemTemp.createTempSync('music_'); folder = Directory.systemTemp.createTempSync('music_');
jellyfin = FakeJellyfin(); jellyfin = FakeJellyfin();
platform = FakeMusicPlatform(); platform = FakeMusicPlatform(jellyfin);
tokens = MemoryJellyfinTokenStore();
library = MusicLibrary( library = MusicLibrary(
MusicStore(File('${folder.path}/music.json')), MusicStore(File('${folder.path}/music.json')),
MemoryJellyfinTokenStore(), tokens,
version: '0.1.0', version: '0.1.0',
client: jellyfin.client, client: jellyfin.client,
); );
@ -37,6 +41,7 @@ void main() {
library, library,
Directory('${folder.path}/music'), Directory('${folder.path}/music'),
platform, platform,
person: 'ada',
); );
}); });
@ -47,13 +52,36 @@ void main() {
File fileOf(String id) => downloads.fileOf(id)!; File fileOf(String id) => downloads.fileOf(id)!;
/// Syncs, has the job run, and takes in what it fetched.
Future<void> syncAndFetch([MusicDownloads? of]) async {
final each = of ?? downloads;
await each.sync();
await platform.run(tokens.token!);
await each.sync();
}
test("a marked playlist's tracks are downloaded and their tempo worked " test("a marked playlist's tracks are downloaded and their tempo worked "
'out, a track in two playlists once', () async { 'out, a track in two playlists once', () async {
library library
..mark(const JellyfinPlaylist(id: 'p1', name: 'Tempo'), on: true) ..mark(const JellyfinPlaylist(id: 'p1', name: 'Tempo'), on: true)
..mark(const JellyfinPlaylist(id: 'p2', name: 'Easy'), on: true); ..mark(const JellyfinPlaylist(id: 'p2', name: 'Easy'), on: true);
await downloads.sync(); await downloads.sync();
final job = platform.job!;
expect(job.person, 'ada');
expect(job.folder, '${folder.path}/music');
expect(job.address, Uri.parse('https://music.example.org'));
expect(job.client, contains('Client="Halkyon Train"'));
expect(job.client, isNot(contains('Token')));
expect(
job.tracks.map((track) => track.path),
unorderedEquals([
for (final id in ['t1', 't2', 't3']) '/Audio/$id/stream?static=true',
]),
);
expect(downloads.kept.value.loading, isTrue);
await platform.run(tokens.token!);
await downloads.sync();
final kept = downloads.kept.value; final kept = downloads.kept.value;
expect(kept.tracks.keys, unorderedEquals(['t1', 't2', 't3'])); expect(kept.tracks.keys, unorderedEquals(['t1', 't2', 't3']));
expect(jellyfin.downloads, unorderedEquals(['t1', 't2', 't3'])); expect(jellyfin.downloads, unorderedEquals(['t1', 't2', 't3']));
@ -68,15 +96,20 @@ void main() {
expect(kept.withTempo, 2); expect(kept.withTempo, 2);
expect(kept.bytes, 'audio of t1'.length * 3); expect(kept.bytes, 'audio of t1'.length * 3);
expect(kept.loading, isFalse);
expect(platform.job, isNull);
expect(File('${folder.path}/music/fetched.txt').existsSync(), isFalse);
// Kept: nothing is downloaded or worked out twice, here or after a // Kept: nothing is downloaded or worked out twice, here or after a
// restart. // restart.
await downloads.sync(); await syncAndFetch();
final again = MusicDownloads( final again = MusicDownloads(
library, library,
Directory('${folder.path}/music'), Directory('${folder.path}/music'),
platform, platform,
person: 'ada',
); );
await again.sync(); await syncAndFetch(again);
await again.dispose(); await again.dispose();
expect(jellyfin.downloads, hasLength(3)); expect(jellyfin.downloads, hasLength(3));
expect(platform.analysed, hasLength(3)); expect(platform.analysed, hasLength(3));
@ -84,15 +117,17 @@ void main() {
test('nothing is downloaded on a metered network, and the wait is ' test('nothing is downloaded on a metered network, and the wait is '
'said', () async { 'said', () async {
platform.unmeteredNow = false;
library.mark(const JellyfinPlaylist(id: 'p1', name: 'Tempo'), on: true); library.mark(const JellyfinPlaylist(id: 'p1', name: 'Tempo'), on: true);
await downloads.sync(); await downloads.sync();
platform.unmeteredNow = false;
await syncAndFetch();
expect(jellyfin.downloads, isEmpty); expect(jellyfin.downloads, isEmpty);
expect(platform.job!.tracks, hasLength(2));
expect(downloads.kept.value.waiting, isTrue); expect(downloads.kept.value.waiting, isTrue);
platform.unmeteredNow = true; platform.unmeteredNow = true;
await downloads.sync(); await syncAndFetch();
expect(jellyfin.downloads, hasLength(2)); expect(jellyfin.downloads, hasLength(2));
expect(downloads.kept.value.waiting, isFalse); expect(downloads.kept.value.waiting, isFalse);
}); });
@ -102,7 +137,7 @@ void main() {
library library
..mark(const JellyfinPlaylist(id: 'p1', name: 'Tempo'), on: true) ..mark(const JellyfinPlaylist(id: 'p1', name: 'Tempo'), on: true)
..mark(const JellyfinPlaylist(id: 'p2', name: 'Easy'), on: true); ..mark(const JellyfinPlaylist(id: 'p2', name: 'Easy'), on: true);
await downloads.sync(); await syncAndFetch();
platform.unmeteredNow = false; platform.unmeteredNow = false;
library.mark(const JellyfinPlaylist(id: 'p1', name: 'Tempo'), on: false); library.mark(const JellyfinPlaylist(id: 'p1', name: 'Tempo'), on: false);
@ -113,6 +148,7 @@ void main() {
await library.signOut(); await library.signOut();
await downloads.sync(); await downloads.sync();
expect(platform.job, isNull);
expect(downloads.kept.value.tracks, isEmpty); expect(downloads.kept.value.tracks, isEmpty);
expect(fileOf('t2').existsSync(), isFalse); expect(fileOf('t2').existsSync(), isFalse);
expect(fileOf('t3').existsSync(), isFalse); expect(fileOf('t3').existsSync(), isFalse);
@ -121,7 +157,7 @@ void main() {
test('a track taken out of its playlist on the server goes; a playlist ' test('a track taken out of its playlist on the server goes; a playlist '
"that can't be read keeps its tracks", () async { "that can't be read keeps its tracks", () async {
library.mark(const JellyfinPlaylist(id: 'p1', name: 'Tempo'), on: true); library.mark(const JellyfinPlaylist(id: 'p1', name: 'Tempo'), on: true);
await downloads.sync(); await syncAndFetch();
jellyfin.tracks['p1'] = [FakeJellyfin.track('t1', 'One')]; jellyfin.tracks['p1'] = [FakeJellyfin.track('t1', 'One')];
await downloads.sync(); await downloads.sync();
@ -138,7 +174,7 @@ void main() {
library library
..mark(const JellyfinPlaylist(id: 'p1', name: 'Tempo'), on: true) ..mark(const JellyfinPlaylist(id: 'p1', name: 'Tempo'), on: true)
..mark(const JellyfinPlaylist(id: 'p2', name: 'Easy'), on: true); ..mark(const JellyfinPlaylist(id: 'p2', name: 'Easy'), on: true);
await downloads.sync(); await syncAndFetch();
jellyfin.tracks.remove('p2'); jellyfin.tracks.remove('p2');
await downloads.sync(); await downloads.sync();
@ -149,29 +185,68 @@ void main() {
expect(downloads.kept.value.tracks.keys, unorderedEquals(['t2', 't3'])); expect(downloads.kept.value.tracks.keys, unorderedEquals(['t2', 't3']));
}); });
test('a track that fails to download is left for the next sync, the ' test('a track that fails to download is left for the job, the others '
'others downloaded; nothing half-written is kept', () async { 'downloaded', () async {
jellyfin.broken.add('t1'); jellyfin.broken.add('t1');
library.mark(const JellyfinPlaylist(id: 'p1', name: 'Tempo'), on: true); library.mark(const JellyfinPlaylist(id: 'p1', name: 'Tempo'), on: true);
await downloads.sync(); await syncAndFetch();
expect(downloads.kept.value.tracks['t1']!.downloaded, isFalse); expect(downloads.kept.value.tracks['t1']!.downloaded, isFalse);
expect(downloads.kept.value.tracks['t2']!.downloaded, isTrue); expect(downloads.kept.value.tracks['t2']!.downloaded, isTrue);
expect( expect(platform.job!.tracks.map((track) => track.id), ['t1']);
Directory('${folder.path}/music').listSync().map((f) => f.path),
everyElement(isNot(endsWith('.part'))),
);
jellyfin.broken.clear(); jellyfin.broken.clear();
await downloads.sync(); await syncAndFetch();
expect(downloads.kept.value.tracks['t1']!.downloaded, isTrue); expect(downloads.kept.value.tracks['t1']!.downloaded, isTrue);
}); });
test('marking a playlist syncs by itself once followed', () async { test('marking a playlist syncs by itself once followed, and each track '
'fetched is taken in as it comes', () async {
downloads.open(); downloads.open();
library.mark(const JellyfinPlaylist(id: 'p2', name: 'Easy'), on: true); library.mark(const JellyfinPlaylist(id: 'p2', name: 'Easy'), on: true);
await downloads.sync(); await downloads.sync();
expect(downloads.kept.value.tracks.keys, unorderedEquals(['t2', 't3'])); expect(downloads.kept.value.tracks.keys, unorderedEquals(['t2', 't3']));
await platform.run(tokens.token!);
await pumpEventQueue();
expect(downloads.kept.value.downloaded, 2);
expect(downloads.kept.value.loading, isFalse);
});
test('a track the job fetched after its playlist was unmarked is '
'deleted as it is taken in', () async {
library.mark(const JellyfinPlaylist(id: 'p2', name: 'Easy'), on: true);
await downloads.sync();
library.mark(const JellyfinPlaylist(id: 'p2', name: 'Easy'), on: false);
final job = platform.job;
await downloads.sync();
platform.job = job;
await platform.run(tokens.token!);
await downloads.sync();
expect(downloads.kept.value.tracks, isEmpty);
expect(fileOf('t2').existsSync(), isFalse);
});
test('a track kept before its tempo was worked out is fetched for it '
'again; one with its tempo is not', () async {
library.mark(const JellyfinPlaylist(id: 'p2', name: 'Easy'), on: true);
await syncAndFetch();
File('${folder.path}/music/tracks.json').writeAsStringSync(
File(
'${folder.path}/music/tracks.json',
).readAsStringSync().replaceFirst('"analysed":true', '"analysed":false'),
);
final again = MusicDownloads(
library,
Directory('${folder.path}/music'),
platform,
person: 'ada',
);
await again.sync();
expect(platform.job!.tracks, hasLength(1));
expect(platform.job!.tracks.single.analyse, isTrue);
await again.dispose();
}); });
test('an ID that could name another file is never one', () { test('an ID that could name another file is never one', () {

View file

@ -102,10 +102,12 @@ void main() {
'pw', 'pw',
); );
}); });
final platform = FakeMusicPlatform(jellyfin);
final kept = MusicDownloads( final kept = MusicDownloads(
library, library,
Directory('${folder.path}/music'), Directory('${folder.path}/music'),
FakeMusicPlatform(), platform,
person: 'ada',
); );
addTearDown(kept.dispose); addTearDown(kept.dispose);
await tester.pumpWidget( await tester.pumpWidget(
@ -126,6 +128,13 @@ void main() {
await tester.runAsync(kept.sync); await tester.runAsync(kept.sync);
await tester.pump(); await tester.pump();
expect(find.text('On this phone'), findsOneWidget); expect(find.text('On this phone'), findsOneWidget);
expect(find.text('Downloading: 0 of 2 tracks'), findsOneWidget);
await tester.runAsync(() async {
await platform.run(tokens.token!);
await kept.sync();
});
await tester.pump();
expect(find.text('2 tracks, 0.0 MB, 2 with a tempo'), findsOneWidget); expect(find.text('2 tracks, 0.0 MB, 2 with a tempo'), findsOneWidget);
await tester.tap(find.text('Delete')); await tester.tap(find.text('Delete'));

View file

@ -1,4 +1,6 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:halkyon_train_flutter/src/feature/music/data/music_platform.dart'; import 'package:halkyon_train_flutter/src/feature/music/data/music_platform.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/music_store.dart'; import 'package:halkyon_train_flutter/src/feature/music/data/music_store.dart';
@ -124,20 +126,67 @@ class FakeJellyfin({
}); });
} }
/// A [MusicPlatform] answering [tempos] by the file's name. /// A [MusicPlatform] whose music job runs when a test says ([run]), as
class FakeMusicPlatform() implements MusicPlatform { /// `MusicFetcher.kt` does: each track from [jellyfin] into the folder, a
/// line each in `fetched.txt`, its tempo from [tempos] by its ID.
class FakeMusicPlatform([final FakeJellyfin? jellyfin])
implements MusicPlatform {
bool unmeteredNow = true; bool unmeteredNow = true;
final tempos = <String, double>{'t1': 128, 't2': 172.5}; final tempos = <String, double>{'t1': 128, 't2': 172.5};
final analysed = <String>[]; final analysed = <String>[];
/// The job as last scheduled; null where none is.
MusicJob? job;
final _fetched = StreamController<void>.broadcast();
@override @override
Future<bool> unmetered() async => unmeteredNow; Future<bool> unmetered() async => unmeteredNow;
@override @override
Future<double?> tempo(String path) async { Future<void> schedule(MusicJob? job) async => this.job = job;
final name = path.split('/').last;
analysed.add(name); @override
return tempos[name]; Stream<void> get fetched => _fetched.stream;
/// Runs the job as scheduled, with the token [token].
Future<void> run(String token) async {
final job = this.job;
if (job == null || !unmeteredNow) {
return;
}
final lines = File('${job.folder}/fetched.txt');
final done = {
if (lines.existsSync())
for (final line in lines.readAsLinesSync())
(jsonDecode(line) as Map<String, Object?>)['id'],
};
for (final track in job.tracks) {
if (done.contains(track.id)) {
continue;
}
final response = await jellyfin!.client.get(
Uri.parse('${job.address}${track.path}'),
headers: {'authorization': '${job.client}, Token="$token"'},
);
if (response.statusCode >= 300) {
continue;
}
final file = File('${job.folder}/${track.id}')
..parent.createSync(recursive: true)
..writeAsBytesSync(response.bodyBytes);
if (track.analyse) {
analysed.add(track.id);
}
lines.writeAsStringSync(
'${jsonEncode({
'id': track.id,
'bytes': file.lengthSync(),
if (track.analyse) ...{'tempo': tempos[track.id], 'analysed': true},
})}\n',
mode: FileMode.append,
);
_fetched.add(null);
}
} }
} }

View file

@ -1348,6 +1348,26 @@ Strava. What was found, 27 Oct:
over the music channel and moves a token the keyring holds under over the music channel and moves a token the keyring holds under
`train_jellyfin:` over as it is first read; keeping one, and forgetting others', `train_jellyfin:` over as it is first read; keeping one, and forgetting others',
clears the keyring's too. clears the keyring's too.
- Built: 14b. `MusicDownloads` reads the playlists as before and hands the tracks
not yet downloaded, or without a tempo worked out, to `MusicPlatform.schedule`
(`MusicJob`: the person, their music folder, the server, Jellyfin's
`Authorization` header without the token, and each track's path), none where no
one is signed in or nothing is missing. `MusicWorker.kt` keeps the list in the
no-backup folder's `music_background.json` and enqueues a periodic run of 6 hours
and a run now, both unique and kept where one waits, under WorkManager's
unmetered network, battery and storage constraints; one run at a time in the
process (a mutex), which reads the list again as it ends and runs again where it
changed. `MusicFetcher.kt` (tested on the JVM against a server of the test's own)
downloads each track through a part file, adds the token from `JellyfinTokens`,
works out its tempo with `MusicBridge.of`, and appends a line to the folder's
`fetched.txt`, skipping what a line names already; a refused token stops the run,
a server out of reach has WorkManager try again later, a track the server fails is
left for the next run. The app takes the lines in as it opens and resumes, and as
`…/music/fetched` says one came while it listens (the file renamed first, so a
line written meanwhile lands in the next); a track no longer kept has its file
deleted. The app's own download (`JellyfinClient.download`) is gone, and so is
the tempo over the channel. Not tried on a phone: WorkManager's runs, the
downloads of large files over HttpURLConnection.
## Consequences ## Consequences