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

View file

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

View file

@ -264,50 +264,13 @@ class JellyfinClient(
];
}
/// Downloads the track [id] as Jellyfin keeps it into [file], through a
/// part file renamed once whole; its size.
Future<int> download(
Uri address,
JellyfinSession session,
String id,
File file,
) 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;
}
}
/// The path and query under the server's address the track [id] is
/// downloaded from, as Jellyfin keeps it (D47, D52).
static String audioPath(String id) => '/Audio/$id/stream?static=true';
/// The `Authorization` header Train asks with, without a token: the
/// music job adds the token it keeps (D52).
String get clientAuthorization => _authorization(null);
/// Ends [session]'s token on the server; what fails is left, as the
/// device forgets the token anyway.

View file

@ -5,7 +5,6 @@ import 'dart:io';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.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/music_library.dart';
@ -18,7 +17,8 @@ final class const KeptMusic({
/// Every track of the marked playlists, by its ID, downloaded or not.
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,
/// Whether downloads wait for an unmetered network.
@ -48,16 +48,22 @@ final class const KeptMusic({
List<Object?> get props => [tracks, loading, waiting];
}
/// Keeps the marked playlists' tracks on this phone (D47): their list read
/// from Jellyfin, each track downloaded as Jellyfin keeps it on unmetered
/// networks only, its tempo worked out once ([MusicPlatform.tempo]); the
/// tracks no marked playlist holds any more deleted, offline too. Files in
/// [_folder] by their IDs, the list in its `tracks.json`.
/// Keeps the marked playlists' tracks on this phone (D47, D52): their list
/// read from Jellyfin as the app opens and resumes, the missing ones handed
/// to the music job ([MusicPlatform.schedule]), which downloads them as
/// Jellyfin keeps them on unmetered networks, the app open or not, and
/// 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(
final MusicLibrary _library,
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(
KeptMusic(tracks: _read()),
);
@ -69,6 +75,7 @@ class MusicDownloads(
);
var _closed = false;
StreamSubscription<void>? _fetched;
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).
void open() {
_library.settings.addListener(_marksChanged);
_fetched = _platform.fetched.listen((_) => _takeIn());
unawaited(sync());
}
@ -90,6 +98,7 @@ class MusicDownloads(
Future<void> dispose() async {
_closed = true;
_library.settings.removeListener(_marksChanged);
await _fetched?.cancel();
kept.dispose();
}
@ -103,110 +112,162 @@ class MusicDownloads(
}
Future<void> _sync() async {
_takeIn();
final settings = _library.settings.value;
_marked = {...settings.playlists.keys};
// What no marked playlist holds goes first, with or without a network.
_keepOnly((track) => track.playlists.any(_marked.contains));
if (!settings.signedIn || _marked.isEmpty || _closed) {
await _schedule();
return;
}
if (!await _platform.unmetered()) {
_set(kept.value.copyWith(waiting: true));
await _schedule();
return;
}
_set(kept.value.copyWith(waiting: false, loading: true));
_set(kept.value.copyWith(loading: true));
try {
final listed = <String, MusicTrack>{};
final unread = <String>{};
for (final playlist in _marked) {
try {
for (final track in await _library.tracks(playlist)) {
final known = listed[track.id] ?? kept.value.tracks[track.id];
listed[track.id] = (known ?? track).copyWith(
name: track.name,
artist: () => track.artist,
album: () => track.album,
length: () => track.length,
playlists: {
if (listed[track.id] case final seen?) ...seen.playlists,
playlist,
},
);
}
} on JellyfinException catch (error) {
if (error.failure == JellyfinFailure.unreachable ||
error.failure == JellyfinFailure.refused) {
return;
}
// A playlist that can't be read keeps what it had.
unread.add(playlist);
}
}
for (final track in kept.value.tracks.values) {
final holding = track.playlists.where(unread.contains);
if (holding.isNotEmpty) {
listed[track.id] = switch (listed[track.id]) {
final read? => read.copyWith(
playlists: {...read.playlists, ...holding},
),
null => track,
};
}
}
// The files of what the playlists no longer hold go before the list
// forgets them.
_keepOnly((track) => listed.containsKey(track.id));
_set(kept.value.copyWith(tracks: listed));
await _download();
await _readPlaylists();
} finally {
if (!_closed) {
_set(kept.value.copyWith(loading: false));
}
await _schedule();
}
}
Future<void> _download() async {
for (final id in [...kept.value.tracks.keys]) {
final track = kept.value.tracks[id];
final file = fileOf(id);
if (track == null || file == null || _closed) {
continue;
}
if (!track.downloaded) {
if (!await _platform.unmetered()) {
_set(kept.value.copyWith(waiting: true));
/// Reads the marked playlists, keeping what a playlist that can't be
/// read had.
Future<void> _readPlaylists() async {
final listed = <String, MusicTrack>{};
final unread = <String>{};
for (final playlist in _marked) {
try {
for (final track in await _library.tracks(playlist)) {
final known = listed[track.id] ?? kept.value.tracks[track.id];
listed[track.id] = (known ?? track).copyWith(
name: track.name,
artist: () => track.artist,
album: () => track.album,
length: () => track.length,
playlists: {
if (listed[track.id] case final seen?) ...seen.playlists,
playlist,
},
);
}
} on JellyfinException catch (error) {
if (error.failure == JellyfinFailure.unreachable ||
error.failure == JellyfinFailure.refused) {
return;
}
final int bytes;
// A playlist that can't be read keeps what it had.
unread.add(playlist);
}
}
for (final track in kept.value.tracks.values) {
final holding = track.playlists.where(unread.contains);
if (holding.isNotEmpty) {
listed[track.id] = switch (listed[track.id]) {
final read? => read.copyWith(
playlists: {...read.playlists, ...holding},
),
null => track,
};
}
}
// The files of what the playlists no longer hold go before the list
// forgets them.
_keepOnly((track) => listed.containsKey(track.id));
_set(kept.value.copyWith(tracks: listed));
}
/// The missing tracks the job is to fetch, or none where no one is
/// signed in or nothing is missing.
List<MusicJobTrack> get _missing => [
for (final track in kept.value.tracks.values)
if (!track.downloaded || !track.analysed)
(
id: track.id,
path: JellyfinClient.audioPath(track.id),
analyse: !track.analysed,
),
];
/// Hands the job the missing tracks, and says whether they wait for an
/// unmetered network.
Future<void> _schedule() async {
if (_closed) {
return;
}
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;
}
await _platform.schedule(
MusicJob(
person: person,
folder: _folder.path,
address: address,
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) {
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 {
bytes = await _library.download(id, file);
} on JellyfinException catch (error) {
if (error.failure == JellyfinFailure.failed) {
// This track alone: the next sync tries it again.
continue;
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(
id,
(track) => fetched['analysed'] == true
? track.copyWith(
bytes: () => bytes,
tempo: () => (fetched['tempo'] as num?)?.toDouble(),
analysed: true,
)
: track.copyWith(bytes: () => bytes),
);
} else {
_deleteFile(id);
}
return;
}
if (_closed) {
return;
}
_update(id, (track) => track.copyWith(bytes: () => bytes));
}
if (!track.analysed) {
double? tempo;
try {
tempo = await _platform.tempo(file.path);
} on PlatformException catch (error) {
log('Tempo not worked out: ${error.code}', name: 'Music');
}
if (_closed) {
return;
}
_update(
id,
(track) => track.copyWith(tempo: () => tempo, analysed: true),
);
}
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));
}
}
@ -228,13 +289,7 @@ class MusicDownloads(
if (gone.isEmpty) {
return;
}
for (final id in gone) {
try {
fileOf(id)?.deleteSync();
} on PathNotFoundException {
// Never downloaded.
}
}
gone.forEach(_deleteFile);
_set(
kept.value.copyWith(
tracks: {
@ -245,6 +300,14 @@ class MusicDownloads(
);
}
void _deleteFile(String id) {
try {
fileOf(id)?.deleteSync();
} on PathNotFoundException {
// Never downloaded.
}
}
Map<String, MusicTrack> _read() {
try {
final json = jsonDecode(_list.readAsStringSync());

View file

@ -1,5 +1,3 @@
import 'dart:io';
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/music_store.dart';
@ -70,11 +68,6 @@ class MusicLibrary(
(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.
Future<T> _asking<T>(
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';
/// 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() {
/// Whether the network is unmetered, as downloads wait for one.
Future<bool> unmetered();
/// The beats per minute of the track in the file at [path]; null where
/// neither its tag nor its beat says one.
Future<double?> tempo(String path);
/// Has the music job fetch [job]'s tracks, now and every few hours while
/// some are missing; nothing more where it is null.
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`
/// (`MusicBridge.kt`): the tag `BpmTag.kt` reads, else `Tempo.kt`'s own
/// analysis of a minute from the track's middle.
/// [MusicPlatform] on Android, over `app.halkyon.train/music` and
/// `…/music/fetched` (`MusicBridge.kt`, `MusicWorker.kt`: a WorkManager job
/// 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({
final MethodChannel _channel = const MethodChannel('app.halkyon.train/music'),
final EventChannel _fetched = const EventChannel(
'app.halkyon.train/music/fetched',
),
}) implements MusicPlatform {
@override
Future<bool> unmetered() async =>
await _channel.invokeMethod<bool>('unmetered') ?? false;
@override
Future<double?> tempo(String path) =>
_channel.invokeMethod<double>('tempo', {'path': path});
Future<void> schedule(MusicJob? job) => _channel.invokeMethod<void>(
'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';
/// The marked playlists kept on this phone (ADR 0030, D47): downloaded on
/// unmetered networks only, each track once, its tempo worked out once,
/// and what no marked playlist holds deleted, offline too.
/// The marked playlists kept on this phone (ADR 0030, D47, D52): the
/// missing tracks handed to the music job, which downloads them on
/// 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() {
late Directory folder;
late FakeJellyfin jellyfin;
late FakeMusicPlatform platform;
late MusicLibrary library;
late MemoryJellyfinTokenStore tokens;
late MusicDownloads downloads;
setUp(() async {
folder = Directory.systemTemp.createTempSync('music_');
jellyfin = FakeJellyfin();
platform = FakeMusicPlatform();
platform = FakeMusicPlatform(jellyfin);
tokens = MemoryJellyfinTokenStore();
library = MusicLibrary(
MusicStore(File('${folder.path}/music.json')),
MemoryJellyfinTokenStore(),
tokens,
version: '0.1.0',
client: jellyfin.client,
);
@ -37,6 +41,7 @@ void main() {
library,
Directory('${folder.path}/music'),
platform,
person: 'ada',
);
});
@ -47,13 +52,36 @@ void main() {
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 "
'out, a track in two playlists once', () async {
library
..mark(const JellyfinPlaylist(id: 'p1', name: 'Tempo'), on: true)
..mark(const JellyfinPlaylist(id: 'p2', name: 'Easy'), on: true);
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;
expect(kept.tracks.keys, unorderedEquals(['t1', 't2', 't3']));
expect(jellyfin.downloads, unorderedEquals(['t1', 't2', 't3']));
@ -68,15 +96,20 @@ void main() {
expect(kept.withTempo, 2);
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
// restart.
await downloads.sync();
await syncAndFetch();
final again = MusicDownloads(
library,
Directory('${folder.path}/music'),
platform,
person: 'ada',
);
await again.sync();
await syncAndFetch(again);
await again.dispose();
expect(jellyfin.downloads, 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 '
'said', () async {
platform.unmeteredNow = false;
library.mark(const JellyfinPlaylist(id: 'p1', name: 'Tempo'), on: true);
await downloads.sync();
platform.unmeteredNow = false;
await syncAndFetch();
expect(jellyfin.downloads, isEmpty);
expect(platform.job!.tracks, hasLength(2));
expect(downloads.kept.value.waiting, isTrue);
platform.unmeteredNow = true;
await downloads.sync();
await syncAndFetch();
expect(jellyfin.downloads, hasLength(2));
expect(downloads.kept.value.waiting, isFalse);
});
@ -102,7 +137,7 @@ void main() {
library
..mark(const JellyfinPlaylist(id: 'p1', name: 'Tempo'), on: true)
..mark(const JellyfinPlaylist(id: 'p2', name: 'Easy'), on: true);
await downloads.sync();
await syncAndFetch();
platform.unmeteredNow = false;
library.mark(const JellyfinPlaylist(id: 'p1', name: 'Tempo'), on: false);
@ -113,6 +148,7 @@ void main() {
await library.signOut();
await downloads.sync();
expect(platform.job, isNull);
expect(downloads.kept.value.tracks, isEmpty);
expect(fileOf('t2').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 '
"that can't be read keeps its tracks", () async {
library.mark(const JellyfinPlaylist(id: 'p1', name: 'Tempo'), on: true);
await downloads.sync();
await syncAndFetch();
jellyfin.tracks['p1'] = [FakeJellyfin.track('t1', 'One')];
await downloads.sync();
@ -138,7 +174,7 @@ void main() {
library
..mark(const JellyfinPlaylist(id: 'p1', name: 'Tempo'), on: true)
..mark(const JellyfinPlaylist(id: 'p2', name: 'Easy'), on: true);
await downloads.sync();
await syncAndFetch();
jellyfin.tracks.remove('p2');
await downloads.sync();
@ -149,29 +185,68 @@ void main() {
expect(downloads.kept.value.tracks.keys, unorderedEquals(['t2', 't3']));
});
test('a track that fails to download is left for the next sync, the '
'others downloaded; nothing half-written is kept', () async {
test('a track that fails to download is left for the job, the others '
'downloaded', () async {
jellyfin.broken.add('t1');
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['t2']!.downloaded, isTrue);
expect(
Directory('${folder.path}/music').listSync().map((f) => f.path),
everyElement(isNot(endsWith('.part'))),
);
expect(platform.job!.tracks.map((track) => track.id), ['t1']);
jellyfin.broken.clear();
await downloads.sync();
await syncAndFetch();
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();
library.mark(const JellyfinPlaylist(id: 'p2', name: 'Easy'), on: true);
await downloads.sync();
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', () {

View file

@ -102,10 +102,12 @@ void main() {
'pw',
);
});
final platform = FakeMusicPlatform(jellyfin);
final kept = MusicDownloads(
library,
Directory('${folder.path}/music'),
FakeMusicPlatform(),
platform,
person: 'ada',
);
addTearDown(kept.dispose);
await tester.pumpWidget(
@ -126,6 +128,13 @@ void main() {
await tester.runAsync(kept.sync);
await tester.pump();
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);
await tester.tap(find.text('Delete'));

View file

@ -1,4 +1,6 @@
import 'dart:async';
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_store.dart';
@ -124,20 +126,67 @@ class FakeJellyfin({
});
}
/// A [MusicPlatform] answering [tempos] by the file's name.
class FakeMusicPlatform() implements MusicPlatform {
/// A [MusicPlatform] whose music job runs when a test says ([run]), as
/// `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;
final tempos = <String, double>{'t1': 128, 't2': 172.5};
final analysed = <String>[];
/// The job as last scheduled; null where none is.
MusicJob? job;
final _fetched = StreamController<void>.broadcast();
@override
Future<bool> unmetered() async => unmeteredNow;
@override
Future<double?> tempo(String path) async {
final name = path.split('/').last;
analysed.add(name);
return tempos[name];
Future<void> schedule(MusicJob? job) async => this.job = job;
@override
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);
}
}
}