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:
parent
409a2519f9
commit
2a7cd927c4
4 changed files with 379 additions and 39 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue