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
21 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

View file

@ -508,6 +508,8 @@
"musicKeptDelete": "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.",
"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",
"recordMusicOn": "Musik",
"recordMetronome": "Metronom",

View file

@ -641,6 +641,8 @@
"musicKeptDelete": "Delete",
"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.",
"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",
"recordMusicOn": "Music",
"@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.'**
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.
///
/// In en, this message translates to:

View file

@ -1831,6 +1831,13 @@ class AppLocalizationsDe extends AppLocalizations {
String get musicKeptDeleteText =>
'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
String get recordMusic => 'Musik';

View file

@ -1819,6 +1819,13 @@ class AppLocalizationsEn extends AppLocalizations {
String get musicKeptDeleteText =>
'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
String get recordMusic => 'Music';

View file

@ -250,6 +250,9 @@ class MusicCubit(
void mark(JellyfinPlaylist playlist, {required bool 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.
void unmarkAll() => _library.unmarkAll();

View file

@ -236,7 +236,7 @@ class JellyfinClient(
address,
'/Playlists/$playlist/Items',
token: session.token,
query: {'userId': session.userId},
query: {'userId': session.userId, 'fields': 'MediaSources'},
);
return [
if (answer case {'Items': final List<Object?> items})
@ -260,13 +260,32 @@ class JellyfinClient(
final int ticks => Duration(microseconds: ticks ~/ 10),
_ => 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
/// downloaded from, as Jellyfin keeps it (D47, D52).
static String audioPath(String id) => '/Audio/$id/stream?static=true';
/// downloaded from as [copy] (D47, D52): as Jellyfin keeps it, or
/// 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
/// 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/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_store.dart';
import 'package:halkyon_train_flutter/src/feature/music/model/music_track.dart';
import 'package:path/path.dart' as p;
@ -103,10 +104,12 @@ class MusicDownloads(
}
var _marked = <String>{};
bool? _smaller;
void _marksChanged() {
final marked = {..._library.settings.value.playlists.keys};
if (!setEquals(marked, _marked)) {
final settings = _library.settings.value;
if (!setEquals({...settings.playlists.keys}, _marked) ||
settings.smaller != _smaller) {
unawaited(sync());
}
}
@ -115,6 +118,7 @@ class MusicDownloads(
_takeIn();
final settings = _library.settings.value;
_marked = {...settings.playlists.keys};
_smaller = settings.smaller;
// What no marked playlist holds goes first, with or without a network.
_keepOnly((track) => track.playlists.any(_marked.contains));
if (!settings.signedIn || _marked.isEmpty || _closed) {
@ -147,6 +151,8 @@ class MusicDownloads(
artist: () => track.artist,
album: () => track.album,
length: () => track.length,
bitrate: () => track.bitrate,
codec: () => track.codec,
playlists: {
if (listed[track.id] case final seen?) ...seen.playlists,
playlist,
@ -179,14 +185,27 @@ class MusicDownloads(
_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.
/// What [track]'s file is to be (D52): a smaller copy where the settings
/// 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 => [
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,
path: JellyfinClient.audioPath(track.id),
copy: copy,
path: JellyfinClient.audioPath(track.id, copy),
analyse: !track.analysed,
),
];
@ -246,16 +265,20 @@ class MusicDownloads(
'bytes': final int bytes,
} &&
final Map<String, Object?> fetched) {
final copy =
MusicCopy.values.asNameMap()[fetched['copy']] ??
MusicCopy.original;
if (kept.value.tracks.containsKey(id)) {
_update(
id,
(track) => fetched['analysed'] == true
? track.copyWith(
bytes: () => bytes,
copy: copy,
tempo: () => (fetched['tempo'] as num?)?.toDouble(),
analysed: true,
)
: track.copyWith(bytes: () => bytes),
: track.copyWith(bytes: () => bytes, copy: copy),
);
} else {
_deleteFile(id);

View file

@ -106,6 +106,11 @@ class MusicLibrary(
void playInRecording(String 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.
void unmarkAll() => _set(settings.value.copyWith(playlists: const {}));
@ -132,6 +137,7 @@ class MusicLibrary(
MusicSettings(
deviceId: settings.value.deviceId,
inRecording: settings.value.inRecording,
smaller: settings.value.smaller,
address: server.address,
serverName: server.name,
serverId: server.id,

View file

@ -2,10 +2,17 @@ import 'dart:convert';
import 'package:equatable/equatable.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
/// query under the server's address, and whether its tempo is wanted.
typedef MusicJobTrack = ({String id, String path, bool analyse});
/// A track for the music job to fetch (ADR 0030, D52): its ID, the copy
/// wanted and the path and query under the server's address it comes from,
/// 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
/// server into their music folder, signed in with the token Train keeps for
@ -27,7 +34,12 @@ final class const MusicJob({
'client': client,
'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
/// name; null before.
final String? inRecording,
/// Whether files above [smallerAbove], but for [lossy] ones, are kept as
/// smaller copies (D52).
final bool smaller = true,
}) 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).
bool get signedIn => address != null && userId != null;
/// Signed out of the server: the device's ID kept, as Jellyfin lists
/// devices by it.
MusicSettings signedOut() =>
MusicSettings(deviceId: deviceId, inRecording: inRecording);
MusicSettings signedOut() => MusicSettings(
deviceId: deviceId,
inRecording: inRecording,
smaller: smaller,
);
MusicSettings copyWith({
Map<String, String>? playlists,
String? inRecording,
bool? smaller,
}) => MusicSettings(
deviceId: deviceId,
address: address,
@ -49,6 +65,7 @@ final class const MusicSettings({
userName: userName,
playlists: playlists ?? this.playlists,
inRecording: inRecording ?? this.inRecording,
smaller: smaller ?? this.smaller,
);
@override
@ -61,6 +78,7 @@ final class const MusicSettings({
userName,
playlists,
inRecording,
smaller,
];
}
@ -90,6 +108,7 @@ class MusicStore(final File _file) {
.cast<String, String>(),
},
inRecording: json['inRecording'] as String?,
smaller: json['smaller'] != false,
);
}
} on Object catch (error) {
@ -113,6 +132,7 @@ class MusicStore(final File _file) {
'userName': settings.userName,
'playlists': settings.playlists,
'inRecording': settings.inRecording,
'smaller': settings.smaller,
}),
);
}

View file

@ -1,5 +1,14 @@
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
/// as this phone keeps it.
final class const MusicTrack({
@ -11,12 +20,21 @@ final class const MusicTrack({
/// Its length as Jellyfin gives it.
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.
final Set<String> playlists = const {},
/// Its file's size once downloaded; null before.
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
/// worked out ([analysed]).
final double? tempo,
@ -31,8 +49,11 @@ final class const MusicTrack({
String? Function()? artist,
String? Function()? album,
Duration? Function()? length,
int? Function()? bitrate,
String? Function()? codec,
Set<String>? playlists,
int? Function()? bytes,
MusicCopy? copy,
double? Function()? tempo,
bool? analysed,
}) => MusicTrack(
@ -41,8 +62,11 @@ final class const MusicTrack({
artist: artist == null ? this.artist : artist(),
album: album == null ? this.album : album(),
length: length == null ? this.length : length(),
bitrate: bitrate == null ? this.bitrate : bitrate(),
codec: codec == null ? this.codec : codec(),
playlists: playlists ?? this.playlists,
bytes: bytes == null ? this.bytes : bytes(),
copy: copy ?? this.copy,
tempo: tempo == null ? this.tempo : tempo(),
analysed: analysed ?? this.analysed,
);
@ -53,8 +77,11 @@ final class const MusicTrack({
'artist': artist,
'album': album,
'ms': length?.inMilliseconds,
'bitrate': bitrate,
'codec': codec,
'playlists': [...playlists],
'bytes': bytes,
'copy': copy.name,
'tempo': tempo,
'analysed': analysed,
};
@ -71,10 +98,14 @@ final class const MusicTrack({
final int ms => Duration(milliseconds: ms),
_ => null,
},
bitrate: json['bitrate'] as int?,
codec: json['codec'] as String?,
playlists: {
...(json['playlists'] as List<Object?>? ?? const []).cast<String>(),
},
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(),
analysed: json['analysed'] == true,
),
@ -88,8 +119,11 @@ final class const MusicTrack({
artist,
album,
length,
bitrate,
codec,
playlists,
bytes,
copy,
tempo,
analysed,
];

View file

@ -284,6 +284,13 @@ class const _Playlists({
ListTile(subtitle: Text(l10n.musicNoPlaylists)),
if (kept case final kept? when settings.playlists.isNotEmpty)
_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>[])
CheckboxListTile(
title: Text(playlist.name),

View file

@ -2,6 +2,7 @@ import 'dart:convert';
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/model/music_track.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
@ -246,8 +247,8 @@ void main() {
},
);
test("a playlist's tracks in its order: their artist, album and length; "
"what isn't audio left out", () async {
test("a playlist's tracks in its order: their artist, album, length and "
"file's bitrate; what isn't audio left out", () async {
final seen = <http.Request>[];
final client = serving({
'GET /jellyfin/Playlists/p1/Items': (
@ -260,6 +261,15 @@ void main() {
'Type': 'Audio',
'Artists': ['Singer', 'Guest'],
'RunTimeTicks': 2100000000,
'MediaSources': [
{
'Bitrate': 912000,
'MediaStreams': [
{'Type': 'Video', 'Codec': 'mjpeg'},
{'Type': 'Audio', 'Codec': 'flac'},
],
},
],
},
{'Id': 'v1', 'Name': 'Clip', 'Type': 'MusicVideo'},
{
@ -282,9 +292,28 @@ void main() {
expect(tracks.map((track) => track.id), ['t1', 't2']);
expect(tracks.first.artist, 'Singer');
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.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 {

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_library.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';
@ -249,6 +250,71 @@ void main() {
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', () {
expect(downloads.fileOf('../music.json'), isNull);
expect(downloads.fileOf('tracks.json'), isNull);

View file

@ -33,6 +33,19 @@ void main() {
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 {
final first = open();
final id = first.settings.value.deviceId;

View file

@ -137,6 +137,13 @@ void main() {
await tester.pump();
expect(find.text('2 tracks, 0.0 MB, 2 with a tempo'), findsOneWidget);
// Smaller copies by default (D52), switched off here.
expect(library.settings.value.smaller, isTrue);
await tester.ensureVisible(find.text('Smaller copies'));
await tester.tap(find.text('Smaller copies'));
await tester.pump();
expect(library.settings.value.smaller, isFalse);
await tester.tap(find.text('Delete'));
await tester.pumpAndSettle();
expect(find.text('Delete the music on this phone?'), findsOneWidget);

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