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
23 changed files with 379 additions and 39 deletions

File diff suppressed because one or more lines are too long

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 * 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 * 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`, * where asked, and a line of JSON each appended to the folder's [FILE] (`id`, `copy`, `bytes`,
* `analysed`), which the app takes in. What a line names already is skipped, as the app may not * `tempo`, `analysed`), which the app takes in. A track whose copy a line names already is
* have taken it in yet. Never logs a track's name. * 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( class MusicFetcher(
private val token: (String) -> String?, private val token: (String) -> String?,
@ -29,8 +30,11 @@ class MusicFetcher(
val tracks: List<Track>, 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 { enum class Outcome {
/** Everything fetched, or skipped for its own error. */ /** Everything fetched, or skipped for its own error. */
@ -52,7 +56,9 @@ class MusicFetcher(
val done = fetchedIds(lines) val done = fetchedIds(lines)
for (track in job.tracks) { for (track in job.tracks) {
if (stopped()) return Outcome.STOPPED 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 file = File(job.folder, track.id)
val part = File(job.folder, "${track.id}.part") val part = File(job.folder, "${track.id}.part")
val connection = URL(job.address.trimEnd('/') + track.path).openConnection() as HttpURLConnection 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 beats = if (track.analyse) tempo(file) else null
val line = 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)) } .apply { if (track.analyse) putAll(mapOf("tempo" to beats, "analysed" to true)) }
FileOutputStream(lines, true).use { out -> FileOutputStream(lines, true).use { out ->
out.write((JsonText.of(line) + "\n").toByteArray()) out.write((JsonText.of(line) + "\n").toByteArray())
out.fd.sync() out.fd.sync()
} }
done += track.id done += "${track.id}/${track.copy}"
fetched() fetched()
} }
return Outcome.DONE return Outcome.DONE
@ -96,10 +102,15 @@ class MusicFetcher(
private const val TIMEOUT_MS = 30_000 private const val TIMEOUT_MS = 30_000
private val ID = Regex("[A-Za-z0-9-]+") 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> = private fun fetchedIds(lines: File): MutableSet<String> =
if (!lines.exists()) mutableSetOf() 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 = tracks =
(0 until tracks.length()).map { (0 until tracks.length()).map {
val track = tracks.getJSONObject(it) 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("audio of t1", File(folder, "t1").readText())
assertEquals( assertEquals(
listOf( listOf(
"""{"id":"t1","bytes":11,"tempo":128.0,"analysed":true}""", """{"id":"t1","copy":"original","bytes":11,"tempo":128.0,"analysed":true}""",
"""{"id":"t2","bytes":11,"tempo":null,"analysed":true}""", """{"id":"t2","copy":"original","bytes":11,"tempo":null,"analysed":true}""",
), ),
lines(), lines(),
) )
@ -114,7 +114,27 @@ class MusicFetcherTest {
fun `a track whose tempo is known is fetched without working it out`() { fun `a track whose tempo is known is fetched without working it out`() {
fetcher().fetch(job("t1", analyse = false)) 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 @Test

View file

@ -508,6 +508,8 @@
"musicKeptDelete": "Löschen", "musicKeptDelete": "Löschen",
"musicKeptDeleteTitle": "Die Musik auf diesem Telefon löschen?", "musicKeptDeleteTitle": "Die Musik auf diesem Telefon löschen?",
"musicKeptDeleteText": "Alle Playlists werden abgewählt und ihre Titel von diesem Telefon gelöscht. Auf deinem Jellyfin-Server bleiben sie.", "musicKeptDeleteText": "Alle Playlists werden abgewählt und ihre Titel von diesem Telefon gelöscht. Auf deinem Jellyfin-Server bleiben sie.",
"musicSmaller": "Kleinere Kopien",
"musicSmallerHint": "Verlustfreie Dateien wie FLAC als Opus mit 128 kbit/s, die dein Server erstellt. MP3 und AAC bleiben, wie sie sind.",
"recordMusic": "Musik", "recordMusic": "Musik",
"recordMusicOn": "Musik", "recordMusicOn": "Musik",
"recordMetronome": "Metronom", "recordMetronome": "Metronom",

View file

@ -641,6 +641,8 @@
"musicKeptDelete": "Delete", "musicKeptDelete": "Delete",
"musicKeptDeleteTitle": "Delete the music on this phone?", "musicKeptDeleteTitle": "Delete the music on this phone?",
"musicKeptDeleteText": "Every playlist is unmarked, and its tracks deleted from this phone. They stay on your Jellyfin server.", "musicKeptDeleteText": "Every playlist is unmarked, and its tracks deleted from this phone. They stay on your Jellyfin server.",
"musicSmaller": "Smaller copies",
"musicSmallerHint": "Lossless files such as FLAC kept as Opus at 128 kbps, which your server makes. MP3 and AAC stay as they are.",
"recordMusic": "Music", "recordMusic": "Music",
"recordMusicOn": "Music", "recordMusicOn": "Music",
"@recordMusicOn": {"description": "The segment playing the kept tracks by cadence."}, "@recordMusicOn": {"description": "The segment playing the kept tracks by cadence."},

View file

@ -3070,6 +3070,18 @@ abstract class AppLocalizations {
/// **'Every playlist is unmarked, and its tracks deleted from this phone. They stay on your Jellyfin server.'** /// **'Every playlist is unmarked, and its tracks deleted from this phone. They stay on your Jellyfin server.'**
String get musicKeptDeleteText; String get musicKeptDeleteText;
/// No description provided for @musicSmaller.
///
/// In en, this message translates to:
/// **'Smaller copies'**
String get musicSmaller;
/// No description provided for @musicSmallerHint.
///
/// In en, this message translates to:
/// **'Lossless files such as FLAC kept as Opus at 128 kbps, which your server makes. MP3 and AAC stay as they are.'**
String get musicSmallerHint;
/// No description provided for @recordMusic. /// No description provided for @recordMusic.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -1831,6 +1831,13 @@ class AppLocalizationsDe extends AppLocalizations {
String get musicKeptDeleteText => String get musicKeptDeleteText =>
'Alle Playlists werden abgewählt und ihre Titel von diesem Telefon gelöscht. Auf deinem Jellyfin-Server bleiben sie.'; 'Alle Playlists werden abgewählt und ihre Titel von diesem Telefon gelöscht. Auf deinem Jellyfin-Server bleiben sie.';
@override
String get musicSmaller => 'Kleinere Kopien';
@override
String get musicSmallerHint =>
'Verlustfreie Dateien wie FLAC als Opus mit 128 kbit/s, die dein Server erstellt. MP3 und AAC bleiben, wie sie sind.';
@override @override
String get recordMusic => 'Musik'; String get recordMusic => 'Musik';

View file

@ -1819,6 +1819,13 @@ class AppLocalizationsEn extends AppLocalizations {
String get musicKeptDeleteText => String get musicKeptDeleteText =>
'Every playlist is unmarked, and its tracks deleted from this phone. They stay on your Jellyfin server.'; 'Every playlist is unmarked, and its tracks deleted from this phone. They stay on your Jellyfin server.';
@override
String get musicSmaller => 'Smaller copies';
@override
String get musicSmallerHint =>
'Lossless files such as FLAC kept as Opus at 128 kbps, which your server makes. MP3 and AAC stay as they are.';
@override @override
String get recordMusic => 'Music'; String get recordMusic => 'Music';

View file

@ -250,6 +250,9 @@ class MusicCubit(
void mark(JellyfinPlaylist playlist, {required bool on}) => void mark(JellyfinPlaylist playlist, {required bool on}) =>
_library.mark(playlist, on: on); _library.mark(playlist, on: on);
/// Keeps smaller copies of large files, or the files as they are (D52).
void keepSmaller({required bool on}) => _library.keepSmaller(on: on);
/// Unmarks every playlist, which deletes their music on this phone. /// Unmarks every playlist, which deletes their music on this phone.
void unmarkAll() => _library.unmarkAll(); void unmarkAll() => _library.unmarkAll();

View file

@ -236,7 +236,7 @@ class JellyfinClient(
address, address,
'/Playlists/$playlist/Items', '/Playlists/$playlist/Items',
token: session.token, token: session.token,
query: {'userId': session.userId}, query: {'userId': session.userId, 'fields': 'MediaSources'},
); );
return [ return [
if (answer case {'Items': final List<Object?> items}) if (answer case {'Items': final List<Object?> items})
@ -260,13 +260,32 @@ class JellyfinClient(
final int ticks => Duration(microseconds: ticks ~/ 10), final int ticks => Duration(microseconds: ticks ~/ 10),
_ => null, _ => null,
}, },
bitrate: switch (item['MediaSources']) {
[{'Bitrate': final int bitrate}, ...] => bitrate,
_ => null,
},
codec: switch (item['MediaSources']) {
[{'MediaStreams': final List<Object?> streams}, ...] =>
streams.whereType<Map<String, Object?>>().firstWhere(
(stream) => stream['Type'] == 'Audio',
orElse: () => const {},
)['Codec']
as String?,
_ => null,
},
), ),
]; ];
} }
/// The path and query under the server's address the track [id] is /// The path and query under the server's address the track [id] is
/// downloaded from, as Jellyfin keeps it (D47, D52). /// downloaded from as [copy] (D47, D52): as Jellyfin keeps it, or
static String audioPath(String id) => '/Audio/$id/stream?static=true'; /// transcoded to Opus at 128 kbps in WebM.
static String audioPath(String id, MusicCopy copy) => switch (copy) {
MusicCopy.original => '/Audio/$id/stream?static=true',
MusicCopy.smaller =>
'/Audio/$id/stream.webm'
'?audioCodec=opus&audioBitRate=128000&maxAudioChannels=2',
};
/// The `Authorization` header Train asks with, without a token: the /// The `Authorization` header Train asks with, without a token: the
/// music job adds the token it keeps (D52). /// music job adds the token it keeps (D52).

View file

@ -9,6 +9,7 @@ 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';
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/model/music_track.dart'; import 'package:halkyon_train_flutter/src/feature/music/model/music_track.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
@ -103,10 +104,12 @@ class MusicDownloads(
} }
var _marked = <String>{}; var _marked = <String>{};
bool? _smaller;
void _marksChanged() { void _marksChanged() {
final marked = {..._library.settings.value.playlists.keys}; final settings = _library.settings.value;
if (!setEquals(marked, _marked)) { if (!setEquals({...settings.playlists.keys}, _marked) ||
settings.smaller != _smaller) {
unawaited(sync()); unawaited(sync());
} }
} }
@ -115,6 +118,7 @@ class MusicDownloads(
_takeIn(); _takeIn();
final settings = _library.settings.value; final settings = _library.settings.value;
_marked = {...settings.playlists.keys}; _marked = {...settings.playlists.keys};
_smaller = settings.smaller;
// 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) {
@ -147,6 +151,8 @@ class MusicDownloads(
artist: () => track.artist, artist: () => track.artist,
album: () => track.album, album: () => track.album,
length: () => track.length, length: () => track.length,
bitrate: () => track.bitrate,
codec: () => track.codec,
playlists: { playlists: {
if (listed[track.id] case final seen?) ...seen.playlists, if (listed[track.id] case final seen?) ...seen.playlists,
playlist, playlist,
@ -179,14 +185,27 @@ class MusicDownloads(
_set(kept.value.copyWith(tracks: listed)); _set(kept.value.copyWith(tracks: listed));
} }
/// The missing tracks the job is to fetch, or none where no one is /// What [track]'s file is to be (D52): a smaller copy where the settings
/// signed in or nothing is missing. /// say so and its file is above [MusicSettings.smallerAbove], but for a
/// lossy codec's.
MusicCopy _wanted(MusicTrack track) =>
_library.settings.value.smaller &&
(track.bitrate ?? 0) > MusicSettings.smallerAbove &&
!MusicSettings.lossy.contains(track.codec?.toLowerCase())
? MusicCopy.smaller
: MusicCopy.original;
/// The tracks the job is to fetch: those missing, without a tempo, or
/// kept as another copy than wanted, which stays until the new one is
/// whole.
List<MusicJobTrack> get _missing => [ List<MusicJobTrack> get _missing => [
for (final track in kept.value.tracks.values) for (final track in kept.value.tracks.values)
if (!track.downloaded || !track.analysed) if (_wanted(track) case final copy
when !track.downloaded || !track.analysed || track.copy != copy)
( (
id: track.id, id: track.id,
path: JellyfinClient.audioPath(track.id), copy: copy,
path: JellyfinClient.audioPath(track.id, copy),
analyse: !track.analysed, analyse: !track.analysed,
), ),
]; ];
@ -246,16 +265,20 @@ class MusicDownloads(
'bytes': final int bytes, 'bytes': final int bytes,
} && } &&
final Map<String, Object?> fetched) { final Map<String, Object?> fetched) {
final copy =
MusicCopy.values.asNameMap()[fetched['copy']] ??
MusicCopy.original;
if (kept.value.tracks.containsKey(id)) { if (kept.value.tracks.containsKey(id)) {
_update( _update(
id, id,
(track) => fetched['analysed'] == true (track) => fetched['analysed'] == true
? track.copyWith( ? track.copyWith(
bytes: () => bytes, bytes: () => bytes,
copy: copy,
tempo: () => (fetched['tempo'] as num?)?.toDouble(), tempo: () => (fetched['tempo'] as num?)?.toDouble(),
analysed: true, analysed: true,
) )
: track.copyWith(bytes: () => bytes), : track.copyWith(bytes: () => bytes, copy: copy),
); );
} else { } else {
_deleteFile(id); _deleteFile(id);

View file

@ -106,6 +106,11 @@ class MusicLibrary(
void playInRecording(String choice) => void playInRecording(String choice) =>
_set(settings.value.copyWith(inRecording: choice)); _set(settings.value.copyWith(inRecording: choice));
/// Keeps smaller copies of files above [MusicSettings.smallerAbove], or
/// the files as they are (D52).
void keepSmaller({required bool on}) =>
_set(settings.value.copyWith(smaller: on));
/// Unmarks every playlist, which deletes their music on this phone. /// Unmarks every playlist, which deletes their music on this phone.
void unmarkAll() => _set(settings.value.copyWith(playlists: const {})); void unmarkAll() => _set(settings.value.copyWith(playlists: const {}));
@ -132,6 +137,7 @@ class MusicLibrary(
MusicSettings( MusicSettings(
deviceId: settings.value.deviceId, deviceId: settings.value.deviceId,
inRecording: settings.value.inRecording, inRecording: settings.value.inRecording,
smaller: settings.value.smaller,
address: server.address, address: server.address,
serverName: server.name, serverName: server.name,
serverId: server.id, serverId: server.id,

View file

@ -2,10 +2,17 @@ import 'dart:convert';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:halkyon_train_flutter/src/feature/music/model/music_track.dart';
/// A track for the music job to fetch (ADR 0030, D52): its ID, the path and /// A track for the music job to fetch (ADR 0030, D52): its ID, the copy
/// query under the server's address, and whether its tempo is wanted. /// wanted and the path and query under the server's address it comes from,
typedef MusicJobTrack = ({String id, String path, bool analyse}); /// and whether its tempo is wanted.
typedef MusicJobTrack = ({
String id,
MusicCopy copy,
String path,
bool analyse,
});
/// What the music job fetches (D52): the person's missing tracks from their /// 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 /// server into their music folder, signed in with the token Train keeps for
@ -27,7 +34,12 @@ final class const MusicJob({
'client': client, 'client': client,
'tracks': [ 'tracks': [
for (final track in tracks) for (final track in tracks)
{'id': track.id, 'path': track.path, 'analyse': track.analyse}, {
'id': track.id,
'copy': track.copy.name,
'path': track.path,
'analyse': track.analyse,
},
], ],
}; };

View file

@ -28,18 +28,34 @@ final class const MusicSettings({
/// What the Record sheet plays, as last chosen (D48): a `MusicChoice`'s /// What the Record sheet plays, as last chosen (D48): a `MusicChoice`'s
/// name; null before. /// name; null before.
final String? inRecording, final String? inRecording,
/// Whether files above [smallerAbove], but for [lossy] ones, are kept as
/// smaller copies (D52).
final bool smaller = true,
}) with Equatable { }) with Equatable {
/// The bits a second above which a smaller copy is kept of a file whose
/// codec isn't one of [lossy] (D52): FLAC, ALAC, WAV and their like.
static const smallerAbove = 256000;
/// The lossy codecs, as Jellyfin names them, whose files are kept as they
/// are at any rate: transcoding them loses their quality twice.
static const lossy = {'mp3', 'aac', 'vorbis', 'opus'};
/// Whether someone is signed in (their token in the keyring). /// Whether someone is signed in (their token in the keyring).
bool get signedIn => address != null && userId != null; bool get signedIn => address != null && userId != null;
/// Signed out of the server: the device's ID kept, as Jellyfin lists /// Signed out of the server: the device's ID kept, as Jellyfin lists
/// devices by it. /// devices by it.
MusicSettings signedOut() => MusicSettings signedOut() => MusicSettings(
MusicSettings(deviceId: deviceId, inRecording: inRecording); deviceId: deviceId,
inRecording: inRecording,
smaller: smaller,
);
MusicSettings copyWith({ MusicSettings copyWith({
Map<String, String>? playlists, Map<String, String>? playlists,
String? inRecording, String? inRecording,
bool? smaller,
}) => MusicSettings( }) => MusicSettings(
deviceId: deviceId, deviceId: deviceId,
address: address, address: address,
@ -49,6 +65,7 @@ final class const MusicSettings({
userName: userName, userName: userName,
playlists: playlists ?? this.playlists, playlists: playlists ?? this.playlists,
inRecording: inRecording ?? this.inRecording, inRecording: inRecording ?? this.inRecording,
smaller: smaller ?? this.smaller,
); );
@override @override
@ -61,6 +78,7 @@ final class const MusicSettings({
userName, userName,
playlists, playlists,
inRecording, inRecording,
smaller,
]; ];
} }
@ -90,6 +108,7 @@ class MusicStore(final File _file) {
.cast<String, String>(), .cast<String, String>(),
}, },
inRecording: json['inRecording'] as String?, inRecording: json['inRecording'] as String?,
smaller: json['smaller'] != false,
); );
} }
} on Object catch (error) { } on Object catch (error) {
@ -113,6 +132,7 @@ class MusicStore(final File _file) {
'userName': settings.userName, 'userName': settings.userName,
'playlists': settings.playlists, 'playlists': settings.playlists,
'inRecording': settings.inRecording, 'inRecording': settings.inRecording,
'smaller': settings.smaller,
}), }),
); );
} }

View file

@ -1,5 +1,14 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
/// What a track's file on the phone is (ADR 0030, D52).
enum MusicCopy() {
/// The file as Jellyfin keeps it.
original,
/// Transcoded by Jellyfin to Opus at 128 kbps in WebM.
smaller,
}
/// A track of a marked playlist (ADR 0030, D47), as Jellyfin names it and /// A track of a marked playlist (ADR 0030, D47), as Jellyfin names it and
/// as this phone keeps it. /// as this phone keeps it.
final class const MusicTrack({ final class const MusicTrack({
@ -11,12 +20,21 @@ final class const MusicTrack({
/// Its length as Jellyfin gives it. /// Its length as Jellyfin gives it.
final Duration? length, final Duration? length,
/// Its file's bits a second on the server, as Jellyfin gives them.
final int? bitrate,
/// Its audio's codec on the server, as Jellyfin names it (`flac`, `mp3`).
final String? codec,
/// The marked playlists holding it, by their IDs. /// The marked playlists holding it, by their IDs.
final Set<String> playlists = const {}, final Set<String> playlists = const {},
/// Its file's size once downloaded; null before. /// Its file's size once downloaded; null before.
final int? bytes, final int? bytes,
/// What the downloaded file is (D52).
final MusicCopy copy = MusicCopy.original,
/// Its beats per minute; null where none was found or before it was /// Its beats per minute; null where none was found or before it was
/// worked out ([analysed]). /// worked out ([analysed]).
final double? tempo, final double? tempo,
@ -31,8 +49,11 @@ final class const MusicTrack({
String? Function()? artist, String? Function()? artist,
String? Function()? album, String? Function()? album,
Duration? Function()? length, Duration? Function()? length,
int? Function()? bitrate,
String? Function()? codec,
Set<String>? playlists, Set<String>? playlists,
int? Function()? bytes, int? Function()? bytes,
MusicCopy? copy,
double? Function()? tempo, double? Function()? tempo,
bool? analysed, bool? analysed,
}) => MusicTrack( }) => MusicTrack(
@ -41,8 +62,11 @@ final class const MusicTrack({
artist: artist == null ? this.artist : artist(), artist: artist == null ? this.artist : artist(),
album: album == null ? this.album : album(), album: album == null ? this.album : album(),
length: length == null ? this.length : length(), length: length == null ? this.length : length(),
bitrate: bitrate == null ? this.bitrate : bitrate(),
codec: codec == null ? this.codec : codec(),
playlists: playlists ?? this.playlists, playlists: playlists ?? this.playlists,
bytes: bytes == null ? this.bytes : bytes(), bytes: bytes == null ? this.bytes : bytes(),
copy: copy ?? this.copy,
tempo: tempo == null ? this.tempo : tempo(), tempo: tempo == null ? this.tempo : tempo(),
analysed: analysed ?? this.analysed, analysed: analysed ?? this.analysed,
); );
@ -53,8 +77,11 @@ final class const MusicTrack({
'artist': artist, 'artist': artist,
'album': album, 'album': album,
'ms': length?.inMilliseconds, 'ms': length?.inMilliseconds,
'bitrate': bitrate,
'codec': codec,
'playlists': [...playlists], 'playlists': [...playlists],
'bytes': bytes, 'bytes': bytes,
'copy': copy.name,
'tempo': tempo, 'tempo': tempo,
'analysed': analysed, 'analysed': analysed,
}; };
@ -71,10 +98,14 @@ final class const MusicTrack({
final int ms => Duration(milliseconds: ms), final int ms => Duration(milliseconds: ms),
_ => null, _ => null,
}, },
bitrate: json['bitrate'] as int?,
codec: json['codec'] as String?,
playlists: { playlists: {
...(json['playlists'] as List<Object?>? ?? const []).cast<String>(), ...(json['playlists'] as List<Object?>? ?? const []).cast<String>(),
}, },
bytes: json['bytes'] as int?, bytes: json['bytes'] as int?,
// Kept before D52: as Jellyfin keeps it.
copy: MusicCopy.values.asNameMap()[json['copy']] ?? MusicCopy.original,
tempo: (json['tempo'] as num?)?.toDouble(), tempo: (json['tempo'] as num?)?.toDouble(),
analysed: json['analysed'] == true, analysed: json['analysed'] == true,
), ),
@ -88,8 +119,11 @@ final class const MusicTrack({
artist, artist,
album, album,
length, length,
bitrate,
codec,
playlists, playlists,
bytes, bytes,
copy,
tempo, tempo,
analysed, analysed,
]; ];

View file

@ -284,6 +284,13 @@ class const _Playlists({
ListTile(subtitle: Text(l10n.musicNoPlaylists)), ListTile(subtitle: Text(l10n.musicNoPlaylists)),
if (kept case final kept? when settings.playlists.isNotEmpty) if (kept case final kept? when settings.playlists.isNotEmpty)
_Kept(kept: kept), _Kept(kept: kept),
SwitchListTile(
secondary: const Icon(LucideIcons.fileAudio),
title: Text(l10n.musicSmaller),
subtitle: Text(l10n.musicSmallerHint),
value: settings.smaller,
onChanged: (on) => cubit().keepSmaller(on: on),
),
for (final playlist in playlists ?? const <JellyfinPlaylist>[]) for (final playlist in playlists ?? const <JellyfinPlaylist>[])
CheckboxListTile( CheckboxListTile(
title: Text(playlist.name), title: Text(playlist.name),

View file

@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.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/model/music_track.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:http/testing.dart'; import 'package:http/testing.dart';
@ -246,8 +247,8 @@ void main() {
}, },
); );
test("a playlist's tracks in its order: their artist, album and length; " test("a playlist's tracks in its order: their artist, album, length and "
"what isn't audio left out", () async { "file's bitrate; what isn't audio left out", () async {
final seen = <http.Request>[]; final seen = <http.Request>[];
final client = serving({ final client = serving({
'GET /jellyfin/Playlists/p1/Items': ( 'GET /jellyfin/Playlists/p1/Items': (
@ -260,6 +261,15 @@ void main() {
'Type': 'Audio', 'Type': 'Audio',
'Artists': ['Singer', 'Guest'], 'Artists': ['Singer', 'Guest'],
'RunTimeTicks': 2100000000, 'RunTimeTicks': 2100000000,
'MediaSources': [
{
'Bitrate': 912000,
'MediaStreams': [
{'Type': 'Video', 'Codec': 'mjpeg'},
{'Type': 'Audio', 'Codec': 'flac'},
],
},
],
}, },
{'Id': 'v1', 'Name': 'Clip', 'Type': 'MusicVideo'}, {'Id': 'v1', 'Name': 'Clip', 'Type': 'MusicVideo'},
{ {
@ -282,9 +292,28 @@ void main() {
expect(tracks.map((track) => track.id), ['t1', 't2']); expect(tracks.map((track) => track.id), ['t1', 't2']);
expect(tracks.first.artist, 'Singer'); expect(tracks.first.artist, 'Singer');
expect(tracks.first.length, const Duration(seconds: 210)); expect(tracks.first.length, const Duration(seconds: 210));
expect(tracks.first.bitrate, 912000);
expect(tracks.first.codec, 'flac');
expect(tracks.last.artist, 'Band'); expect(tracks.last.artist, 'Band');
expect(tracks.last.album, 'Album'); expect(tracks.last.album, 'Album');
expect(seen.single.url.queryParameters, {'userId': 'user-1'}); expect(tracks.last.bitrate, isNull);
expect(seen.single.url.queryParameters, {
'userId': 'user-1',
'fields': 'MediaSources',
});
});
test('a track comes as Jellyfin keeps it, or transcoded to Opus at '
'128 kbps in WebM (D52)', () {
expect(
JellyfinClient.audioPath('t1', MusicCopy.original),
'/Audio/t1/stream?static=true',
);
expect(
JellyfinClient.audioPath('t1', MusicCopy.smaller),
'/Audio/t1/stream.webm'
'?audioCodec=opus&audioBitRate=128000&maxAudioChannels=2',
);
}); });
test('a token Jellyfin no longer takes is refused', () async { test('a token Jellyfin no longer takes is refused', () async {

View file

@ -5,6 +5,7 @@ import 'package:halkyon_train_flutter/src/feature/music/data/jellyfin_client.dar
import 'package:halkyon_train_flutter/src/feature/music/data/music_downloads.dart'; import 'package:halkyon_train_flutter/src/feature/music/data/music_downloads.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';
import 'package:halkyon_train_flutter/src/feature/music/data/music_store.dart'; import 'package:halkyon_train_flutter/src/feature/music/data/music_store.dart';
import 'package:halkyon_train_flutter/src/feature/music/model/music_track.dart';
import '../../support/fake_jellyfin.dart'; import '../../support/fake_jellyfin.dart';
@ -249,6 +250,71 @@ void main() {
await again.dispose(); await again.dispose();
}); });
group('smaller copies (D52)', () {
setUp(() {
jellyfin.tracks = {
'p1': [
FakeJellyfin.track('t1', 'Lossless', codec: 'flac', bitrate: 912000),
FakeJellyfin.track('t2', 'MP3'),
FakeJellyfin.track('t3', 'Small', codec: 'alac', bitrate: 256000),
],
};
library.mark(const JellyfinPlaylist(id: 'p1', name: 'Tempo'), on: true);
});
test(
'by default, a lossless file above 256 kbps comes transcoded; a '
'lossy one at 320 kbps, and one at 256 or below, as they are',
() async {
await syncAndFetch();
final tracks = downloads.kept.value.tracks;
expect(tracks['t1']!.copy, MusicCopy.smaller);
expect(fileOf('t1').readAsStringSync(), 'opus of t1');
expect(tracks['t2']!.copy, MusicCopy.original);
expect(fileOf('t2').readAsStringSync(), 'audio of t2');
expect(tracks['t3']!.copy, MusicCopy.original);
expect(tracks['t1']!.tempo, 128);
expect(platform.job, isNull);
},
);
test('switched off, the smaller copy is fetched again as it is, its '
'tempo kept and not worked out again; switched on, back', () async {
await syncAndFetch();
expect(platform.analysed, hasLength(3));
library.keepSmaller(on: false);
await downloads.sync();
final job = platform.job!;
expect(job.tracks.map((track) => track.id), ['t1']);
expect(job.tracks.single.copy, MusicCopy.original);
expect(job.tracks.single.analyse, isFalse);
// Kept until the new one is whole.
expect(fileOf('t1').readAsStringSync(), 'opus of t1');
await platform.run(tokens.token!);
await downloads.sync();
final t1 = downloads.kept.value.tracks['t1']!;
expect(t1.copy, MusicCopy.original);
expect(fileOf('t1').readAsStringSync(), 'audio of t1');
expect(t1.tempo, 128);
expect(platform.analysed, hasLength(3));
expect(platform.job, isNull);
library.keepSmaller(on: true);
await syncAndFetch();
expect(downloads.kept.value.tracks['t1']!.copy, MusicCopy.smaller);
});
test('a track kept before D52 counts as kept as it is', () {
expect(
MusicTrack.fromJson({'id': 't1', 'name': 'One', 'bytes': 3})!.copy,
MusicCopy.original,
);
});
});
test('an ID that could name another file is never one', () { test('an ID that could name another file is never one', () {
expect(downloads.fileOf('../music.json'), isNull); expect(downloads.fileOf('../music.json'), isNull);
expect(downloads.fileOf('tracks.json'), isNull); expect(downloads.fileOf('tracks.json'), isNull);

View file

@ -33,6 +33,19 @@ void main() {
client: jellyfin.client, client: jellyfin.client,
); );
test('smaller copies by default, the choice kept, signed out and in '
'again too (D52)', () async {
final library = open();
expect(library.settings.value.smaller, isTrue);
library.keepSmaller(on: false);
expect(open().settings.value.smaller, isFalse);
await library.signInWithPassword(await library.find(address), 'Ada', 'pw');
await library.signOut();
expect(open().settings.value.smaller, isFalse);
});
test('a device ID is made once and kept, and Jellyfin is told it', () async { test('a device ID is made once and kept, and Jellyfin is told it', () async {
final first = open(); final first = open();
final id = first.settings.value.deviceId; final id = first.settings.value.deviceId;

Some files were not shown because too many files have changed in this diff Show more