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