feat(train): marked playlists kept on the phone, their tempo worked out (ADR 0030, D47)
Music's second part: the marked playlists' tracks are downloaded as Jellyfin keeps them, on unmetered networks only, while the app is open or resumes, and deleted once no marked playlist holds them. Each track's tempo is worked out once on the phone: a BPM tag where the file has one, else a minute decoded with MediaCodec and its spectral flux autocorrelated (Tempo.kt, tested on the JVM). The music screen shows what is kept and deletes it after asking. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
3336fe4eca
commit
7f18f001f4
3 changed files with 1676 additions and 24 deletions
|
|
@ -246,6 +246,47 @@ void main() {
|
|||
},
|
||||
);
|
||||
|
||||
test("a playlist's tracks in its order: their artist, album and length; "
|
||||
"what isn't audio left out", () async {
|
||||
final seen = <http.Request>[];
|
||||
final client = serving({
|
||||
'GET /jellyfin/Playlists/p1/Items': (
|
||||
200,
|
||||
{
|
||||
'Items': [
|
||||
{
|
||||
'Id': 't1',
|
||||
'Name': 'One',
|
||||
'Type': 'Audio',
|
||||
'Artists': ['Singer', 'Guest'],
|
||||
'RunTimeTicks': 2100000000,
|
||||
},
|
||||
{'Id': 'v1', 'Name': 'Clip', 'Type': 'MusicVideo'},
|
||||
{
|
||||
'Id': 't2',
|
||||
'Name': 'Two',
|
||||
'Type': 'Audio',
|
||||
'AlbumArtist': 'Band',
|
||||
'Album': 'Album',
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
}, seen: seen);
|
||||
|
||||
final tracks = await client.tracks(address, (
|
||||
token: 'token-1',
|
||||
userId: 'user-1',
|
||||
), 'p1');
|
||||
|
||||
expect(tracks.map((track) => track.id), ['t1', 't2']);
|
||||
expect(tracks.first.artist, 'Singer');
|
||||
expect(tracks.first.length, const Duration(seconds: 210));
|
||||
expect(tracks.last.artist, 'Band');
|
||||
expect(tracks.last.album, 'Album');
|
||||
expect(seen.single.url.queryParameters, {'userId': 'user-1'});
|
||||
});
|
||||
|
||||
test('a token Jellyfin no longer takes is refused', () async {
|
||||
await expectLater(
|
||||
serving({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,181 @@
|
|||
import 'dart:io';
|
||||
|
||||
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/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 '../../support/fake_jellyfin.dart';
|
||||
|
||||
/// The marked playlists kept on this phone (ADR 0030, D47): downloaded on
|
||||
/// unmetered networks only, each track once, its tempo worked out once,
|
||||
/// and what no marked playlist holds deleted, offline too.
|
||||
void main() {
|
||||
late Directory folder;
|
||||
late FakeJellyfin jellyfin;
|
||||
late FakeMusicPlatform platform;
|
||||
late MusicLibrary library;
|
||||
late MusicDownloads downloads;
|
||||
|
||||
setUp(() async {
|
||||
folder = Directory.systemTemp.createTempSync('music_');
|
||||
jellyfin = FakeJellyfin();
|
||||
platform = FakeMusicPlatform();
|
||||
library = MusicLibrary(
|
||||
MusicStore(File('${folder.path}/music.json')),
|
||||
MemoryJellyfinTokenStore(),
|
||||
version: '0.1.0',
|
||||
client: jellyfin.client,
|
||||
);
|
||||
await library.signInWithPassword(
|
||||
await library.find(Uri.parse('https://music.example.org')),
|
||||
'Ada',
|
||||
'pw',
|
||||
);
|
||||
downloads = MusicDownloads(
|
||||
library,
|
||||
Directory('${folder.path}/music'),
|
||||
platform,
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await downloads.dispose();
|
||||
folder.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
File fileOf(String id) => downloads.fileOf(id)!;
|
||||
|
||||
test("a marked playlist's tracks are downloaded and their tempo worked "
|
||||
'out, a track in two playlists once', () async {
|
||||
library
|
||||
..mark(const JellyfinPlaylist(id: 'p1', name: 'Tempo'), on: true)
|
||||
..mark(const JellyfinPlaylist(id: 'p2', name: 'Easy'), on: true);
|
||||
await downloads.sync();
|
||||
|
||||
final kept = downloads.kept.value;
|
||||
expect(kept.tracks.keys, unorderedEquals(['t1', 't2', 't3']));
|
||||
expect(jellyfin.downloads, unorderedEquals(['t1', 't2', 't3']));
|
||||
expect(fileOf('t1').readAsStringSync(), 'audio of t1');
|
||||
expect(kept.tracks['t1']!.tempo, 128);
|
||||
expect(kept.tracks['t2']!.playlists, {'p1', 'p2'});
|
||||
expect(kept.tracks['t3']!.tempo, isNull);
|
||||
expect(kept.tracks['t3']!.analysed, isTrue);
|
||||
expect(kept.tracks['t1']!.artist, 'Band');
|
||||
expect(kept.tracks['t1']!.length, const Duration(minutes: 3));
|
||||
expect(kept.downloaded, 3);
|
||||
expect(kept.withTempo, 2);
|
||||
expect(kept.bytes, 'audio of t1'.length * 3);
|
||||
|
||||
// Kept: nothing is downloaded or worked out twice, here or after a
|
||||
// restart.
|
||||
await downloads.sync();
|
||||
final again = MusicDownloads(
|
||||
library,
|
||||
Directory('${folder.path}/music'),
|
||||
platform,
|
||||
);
|
||||
await again.sync();
|
||||
await again.dispose();
|
||||
expect(jellyfin.downloads, hasLength(3));
|
||||
expect(platform.analysed, hasLength(3));
|
||||
});
|
||||
|
||||
test('nothing is downloaded on a metered network, and the wait is '
|
||||
'said', () async {
|
||||
platform.unmeteredNow = false;
|
||||
library.mark(const JellyfinPlaylist(id: 'p1', name: 'Tempo'), on: true);
|
||||
await downloads.sync();
|
||||
|
||||
expect(jellyfin.downloads, isEmpty);
|
||||
expect(downloads.kept.value.waiting, isTrue);
|
||||
|
||||
platform.unmeteredNow = true;
|
||||
await downloads.sync();
|
||||
expect(jellyfin.downloads, hasLength(2));
|
||||
expect(downloads.kept.value.waiting, isFalse);
|
||||
});
|
||||
|
||||
test('a playlist unmarked loses the tracks no other marked one holds, '
|
||||
'offline too; signing out deletes all', () async {
|
||||
library
|
||||
..mark(const JellyfinPlaylist(id: 'p1', name: 'Tempo'), on: true)
|
||||
..mark(const JellyfinPlaylist(id: 'p2', name: 'Easy'), on: true);
|
||||
await downloads.sync();
|
||||
|
||||
platform.unmeteredNow = false;
|
||||
library.mark(const JellyfinPlaylist(id: 'p1', name: 'Tempo'), on: false);
|
||||
await downloads.sync();
|
||||
expect(downloads.kept.value.tracks.keys, unorderedEquals(['t2', 't3']));
|
||||
expect(fileOf('t1').existsSync(), isFalse);
|
||||
expect(fileOf('t2').existsSync(), isTrue);
|
||||
|
||||
await library.signOut();
|
||||
await downloads.sync();
|
||||
expect(downloads.kept.value.tracks, isEmpty);
|
||||
expect(fileOf('t2').existsSync(), isFalse);
|
||||
expect(fileOf('t3').existsSync(), isFalse);
|
||||
});
|
||||
|
||||
test('a track taken out of its playlist on the server goes; a playlist '
|
||||
"that can't be read keeps its tracks", () async {
|
||||
library.mark(const JellyfinPlaylist(id: 'p1', name: 'Tempo'), on: true);
|
||||
await downloads.sync();
|
||||
|
||||
jellyfin.tracks['p1'] = [FakeJellyfin.track('t1', 'One')];
|
||||
await downloads.sync();
|
||||
expect(downloads.kept.value.tracks.keys, ['t1']);
|
||||
expect(fileOf('t2').existsSync(), isFalse);
|
||||
|
||||
jellyfin.tracks.remove('p1');
|
||||
await downloads.sync();
|
||||
expect(downloads.kept.value.tracks.keys, ['t1']);
|
||||
});
|
||||
|
||||
test("a track also held by a playlist that can't be read stays when "
|
||||
'the other is unmarked', () async {
|
||||
library
|
||||
..mark(const JellyfinPlaylist(id: 'p1', name: 'Tempo'), on: true)
|
||||
..mark(const JellyfinPlaylist(id: 'p2', name: 'Easy'), on: true);
|
||||
await downloads.sync();
|
||||
|
||||
jellyfin.tracks.remove('p2');
|
||||
await downloads.sync();
|
||||
expect(downloads.kept.value.tracks['t2']!.playlists, {'p1', 'p2'});
|
||||
|
||||
library.mark(const JellyfinPlaylist(id: 'p1', name: 'Tempo'), on: false);
|
||||
await downloads.sync();
|
||||
expect(downloads.kept.value.tracks.keys, unorderedEquals(['t2', 't3']));
|
||||
});
|
||||
|
||||
test('a track that fails to download is left for the next sync, the '
|
||||
'others downloaded; nothing half-written is kept', () async {
|
||||
jellyfin.broken.add('t1');
|
||||
library.mark(const JellyfinPlaylist(id: 'p1', name: 'Tempo'), on: true);
|
||||
await downloads.sync();
|
||||
|
||||
expect(downloads.kept.value.tracks['t1']!.downloaded, isFalse);
|
||||
expect(downloads.kept.value.tracks['t2']!.downloaded, isTrue);
|
||||
expect(
|
||||
Directory('${folder.path}/music').listSync().map((f) => f.path),
|
||||
everyElement(isNot(endsWith('.part'))),
|
||||
);
|
||||
|
||||
jellyfin.broken.clear();
|
||||
await downloads.sync();
|
||||
expect(downloads.kept.value.tracks['t1']!.downloaded, isTrue);
|
||||
});
|
||||
|
||||
test('marking a playlist syncs by itself once followed', () async {
|
||||
downloads.open();
|
||||
library.mark(const JellyfinPlaylist(id: 'p2', name: 'Easy'), on: true);
|
||||
await downloads.sync();
|
||||
expect(downloads.kept.value.tracks.keys, unorderedEquals(['t2', 't3']));
|
||||
});
|
||||
|
||||
test('an ID that could name another file is never one', () {
|
||||
expect(downloads.fileOf('../music.json'), isNull);
|
||||
expect(downloads.fileOf('tracks.json'), isNull);
|
||||
});
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/music/bloc/music_cubit.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_store.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/music/widget/music_screen.dart';
|
||||
|
|
@ -85,6 +86,61 @@ void main() {
|
|||
expect(tokens.token, isNull);
|
||||
});
|
||||
|
||||
testWidgets('what the phone keeps of the marked playlists, and deleting '
|
||||
'it after asking', (tester) async {
|
||||
final jellyfin = FakeJellyfin();
|
||||
final library = MusicLibrary(
|
||||
MusicStore(File('${folder.path}/music.json')),
|
||||
tokens,
|
||||
version: '0.1.0',
|
||||
client: jellyfin.client,
|
||||
);
|
||||
await tester.runAsync(() async {
|
||||
await library.signInWithPassword(
|
||||
await library.find(Uri.parse('https://music.example.org')),
|
||||
'Ada',
|
||||
'pw',
|
||||
);
|
||||
});
|
||||
final kept = MusicDownloads(
|
||||
library,
|
||||
Directory('${folder.path}/music'),
|
||||
FakeMusicPlatform(),
|
||||
);
|
||||
addTearDown(kept.dispose);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
locale: const Locale('en'),
|
||||
home: BlocProvider(
|
||||
create: (context) => MusicCubit(library)..open(),
|
||||
child: MusicScreen(kept: kept),
|
||||
),
|
||||
),
|
||||
);
|
||||
await settle(tester);
|
||||
expect(find.text('On this phone'), findsNothing);
|
||||
|
||||
await tester.tap(find.text('Tempo'));
|
||||
await tester.runAsync(kept.sync);
|
||||
await tester.pump();
|
||||
expect(find.text('On this phone'), findsOneWidget);
|
||||
expect(find.text('2 tracks, 0.0 MB, 2 with a tempo'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Delete'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Delete the music on this phone?'), findsOneWidget);
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.runAsync(kept.sync);
|
||||
await tester.pump();
|
||||
|
||||
expect(library.settings.value.playlists, isEmpty);
|
||||
expect(kept.kept.value.tracks, isEmpty);
|
||||
expect(find.text('On this phone'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('without Quick Connect, the password; a wrong one said so', (
|
||||
tester,
|
||||
) async {
|
||||
|
|
|
|||
Loading…
Reference in a new issue