feat(train): smaller copies of lossless tracks, transcoded by Jellyfin to Opus (ADR 0030, D52)

Smaller copies, on by default on the music screen, has the music job
fetch a file above 256 kbps as Opus at 128 kbps in WebM, but for the
lossy codecs (MP3, AAC, Vorbis, Opus) at any rate. Each track keeps
which copy its file is; switching fetches the other copy without
working out the tempo again, the file kept until the new one is whole.

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 19:09:06 +01:00
commit 2a7cd927c4
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 379 additions and 39 deletions

View file

@ -9,9 +9,10 @@ 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.
* where asked, and a line of JSON each appended to the folder's [FILE] (`id`, `copy`, `bytes`,
* `tempo`, `analysed`), which the app takes in. A track whose copy a line names already is
* skipped, as the app may not have taken it in yet. A new copy replaces the file kept only once
* it is whole. Never logs a track's name.
*/
class MusicFetcher(
private val token: (String) -> String?,
@ -29,8 +30,11 @@ class MusicFetcher(
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)
/**
* A track to fetch: its ID, the copy wanted (`original` or `smaller`, D52, handed back in its
* line), 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, val copy: String = "original")
enum class Outcome {
/** Everything fetched, or skipped for its own error. */
@ -52,7 +56,9 @@ class MusicFetcher(
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
if ("${track.id}/${track.copy}" 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
@ -78,13 +84,13 @@ class MusicFetcher(
}
val beats = if (track.analyse) tempo(file) else null
val line =
mutableMapOf<String, Any?>("id" to track.id, "bytes" to file.length())
mutableMapOf<String, Any?>("id" to track.id, "copy" to track.copy, "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
done += "${track.id}/${track.copy}"
fetched()
}
return Outcome.DONE
@ -96,10 +102,15 @@ class MusicFetcher(
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 val LINE = Regex("^\\{\"id\":\"([A-Za-z0-9-]+)\",\"copy\":\"(\\w+)\"")
/** What the lines name, as `<id>/<copy>`. */
private fun fetchedIds(lines: File): MutableSet<String> =
if (!lines.exists()) mutableSetOf()
else lines.readLines().mapNotNull { LINE_ID.find(it)?.groupValues?.get(1) }.toMutableSet()
else
lines
.readLines()
.mapNotNull { LINE.find(it)?.groupValues?.let { (_, id, copy) -> "$id/$copy" } }
.toMutableSet()
}
}

View file

@ -119,7 +119,12 @@ class MusicWorker(context: Context, parameters: WorkerParameters) : CoroutineWor
tracks =
(0 until tracks.length()).map {
val track = tracks.getJSONObject(it)
MusicFetcher.Track(track.getString("id"), track.getString("path"), track.optBoolean("analyse", true))
MusicFetcher.Track(
track.getString("id"),
track.getString("path"),
track.optBoolean("analyse", true),
track.optString("copy", "original"),
)
},
)
}

View file

@ -91,8 +91,8 @@ class MusicFetcherTest {
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}""",
"""{"id":"t1","copy":"original","bytes":11,"tempo":128.0,"analysed":true}""",
"""{"id":"t2","copy":"original","bytes":11,"tempo":null,"analysed":true}""",
),
lines(),
)
@ -114,7 +114,27 @@ class MusicFetcherTest {
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())
assertEquals(listOf("""{"id":"t1","copy":"original","bytes":11}"""), lines())
}
@Test
fun `a smaller copy replaces the file kept once whole, though a line names the original`() {
fetcher().fetch(job("t1"))
val smaller =
MusicFetcher.Job(
person = "ada",
folder = folder,
address = address,
client = "MediaBrowser",
tracks = listOf(MusicFetcher.Track("t1", "/Audio/t1/stream.webm?audioCodec=opus", false, "smaller")),
)
fetcher().fetch(smaller)
fetcher().fetch(smaller)
assertEquals("/jellyfin/Audio/t1/stream.webm?audioCodec=opus", asked.last())
assertEquals(2, asked.size)
assertEquals("""{"id":"t1","copy":"smaller","bytes":11}""", lines().last())
assertTrue(File(folder, "t1").exists())
}
@Test