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

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);