feat(train): the marked playlists downloaded by a WorkManager job, the app open or not (ADR 0030, D52)

The app reads the playlists as before and hands the missing tracks to
MusicWorker.kt, which runs every 6 hours and once whenever the list
changes, on unmetered networks with the battery and storage not low.
MusicFetcher.kt downloads each with the token from JellyfinTokens, works
out its tempo and appends a line to fetched.txt, which the app takes in
as it opens and as each track comes. The app's own download is gone.

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 18:57:19 +01:00
commit 409a2519f9
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 830 additions and 207 deletions

View file

@ -8,23 +8,27 @@ 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.
/// The marked playlists kept on this phone (ADR 0030, D47, D52): the
/// missing tracks handed to the music job, which downloads them on
/// unmetered networks only, each track once, its tempo worked out once;
/// what it fetched taken in; what no marked playlist holds deleted, offline
/// too.
void main() {
late Directory folder;
late FakeJellyfin jellyfin;
late FakeMusicPlatform platform;
late MusicLibrary library;
late MemoryJellyfinTokenStore tokens;
late MusicDownloads downloads;
setUp(() async {
folder = Directory.systemTemp.createTempSync('music_');
jellyfin = FakeJellyfin();
platform = FakeMusicPlatform();
platform = FakeMusicPlatform(jellyfin);
tokens = MemoryJellyfinTokenStore();
library = MusicLibrary(
MusicStore(File('${folder.path}/music.json')),
MemoryJellyfinTokenStore(),
tokens,
version: '0.1.0',
client: jellyfin.client,
);
@ -37,6 +41,7 @@ void main() {
library,
Directory('${folder.path}/music'),
platform,
person: 'ada',
);
});
@ -47,13 +52,36 @@ void main() {
File fileOf(String id) => downloads.fileOf(id)!;
/// Syncs, has the job run, and takes in what it fetched.
Future<void> syncAndFetch([MusicDownloads? of]) async {
final each = of ?? downloads;
await each.sync();
await platform.run(tokens.token!);
await each.sync();
}
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 job = platform.job!;
expect(job.person, 'ada');
expect(job.folder, '${folder.path}/music');
expect(job.address, Uri.parse('https://music.example.org'));
expect(job.client, contains('Client="Halkyon Train"'));
expect(job.client, isNot(contains('Token')));
expect(
job.tracks.map((track) => track.path),
unorderedEquals([
for (final id in ['t1', 't2', 't3']) '/Audio/$id/stream?static=true',
]),
);
expect(downloads.kept.value.loading, isTrue);
await platform.run(tokens.token!);
await downloads.sync();
final kept = downloads.kept.value;
expect(kept.tracks.keys, unorderedEquals(['t1', 't2', 't3']));
expect(jellyfin.downloads, unorderedEquals(['t1', 't2', 't3']));
@ -68,15 +96,20 @@ void main() {
expect(kept.withTempo, 2);
expect(kept.bytes, 'audio of t1'.length * 3);
expect(kept.loading, isFalse);
expect(platform.job, isNull);
expect(File('${folder.path}/music/fetched.txt').existsSync(), isFalse);
// Kept: nothing is downloaded or worked out twice, here or after a
// restart.
await downloads.sync();
await syncAndFetch();
final again = MusicDownloads(
library,
Directory('${folder.path}/music'),
platform,
person: 'ada',
);
await again.sync();
await syncAndFetch(again);
await again.dispose();
expect(jellyfin.downloads, hasLength(3));
expect(platform.analysed, hasLength(3));
@ -84,15 +117,17 @@ void main() {
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();
platform.unmeteredNow = false;
await syncAndFetch();
expect(jellyfin.downloads, isEmpty);
expect(platform.job!.tracks, hasLength(2));
expect(downloads.kept.value.waiting, isTrue);
platform.unmeteredNow = true;
await downloads.sync();
await syncAndFetch();
expect(jellyfin.downloads, hasLength(2));
expect(downloads.kept.value.waiting, isFalse);
});
@ -102,7 +137,7 @@ void main() {
library
..mark(const JellyfinPlaylist(id: 'p1', name: 'Tempo'), on: true)
..mark(const JellyfinPlaylist(id: 'p2', name: 'Easy'), on: true);
await downloads.sync();
await syncAndFetch();
platform.unmeteredNow = false;
library.mark(const JellyfinPlaylist(id: 'p1', name: 'Tempo'), on: false);
@ -113,6 +148,7 @@ void main() {
await library.signOut();
await downloads.sync();
expect(platform.job, isNull);
expect(downloads.kept.value.tracks, isEmpty);
expect(fileOf('t2').existsSync(), isFalse);
expect(fileOf('t3').existsSync(), isFalse);
@ -121,7 +157,7 @@ void main() {
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();
await syncAndFetch();
jellyfin.tracks['p1'] = [FakeJellyfin.track('t1', 'One')];
await downloads.sync();
@ -138,7 +174,7 @@ void main() {
library
..mark(const JellyfinPlaylist(id: 'p1', name: 'Tempo'), on: true)
..mark(const JellyfinPlaylist(id: 'p2', name: 'Easy'), on: true);
await downloads.sync();
await syncAndFetch();
jellyfin.tracks.remove('p2');
await downloads.sync();
@ -149,29 +185,68 @@ void main() {
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 {
test('a track that fails to download is left for the job, the others '
'downloaded', () async {
jellyfin.broken.add('t1');
library.mark(const JellyfinPlaylist(id: 'p1', name: 'Tempo'), on: true);
await downloads.sync();
await syncAndFetch();
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'))),
);
expect(platform.job!.tracks.map((track) => track.id), ['t1']);
jellyfin.broken.clear();
await downloads.sync();
await syncAndFetch();
expect(downloads.kept.value.tracks['t1']!.downloaded, isTrue);
});
test('marking a playlist syncs by itself once followed', () async {
test('marking a playlist syncs by itself once followed, and each track '
'fetched is taken in as it comes', () 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']));
await platform.run(tokens.token!);
await pumpEventQueue();
expect(downloads.kept.value.downloaded, 2);
expect(downloads.kept.value.loading, isFalse);
});
test('a track the job fetched after its playlist was unmarked is '
'deleted as it is taken in', () async {
library.mark(const JellyfinPlaylist(id: 'p2', name: 'Easy'), on: true);
await downloads.sync();
library.mark(const JellyfinPlaylist(id: 'p2', name: 'Easy'), on: false);
final job = platform.job;
await downloads.sync();
platform.job = job;
await platform.run(tokens.token!);
await downloads.sync();
expect(downloads.kept.value.tracks, isEmpty);
expect(fileOf('t2').existsSync(), isFalse);
});
test('a track kept before its tempo was worked out is fetched for it '
'again; one with its tempo is not', () async {
library.mark(const JellyfinPlaylist(id: 'p2', name: 'Easy'), on: true);
await syncAndFetch();
File('${folder.path}/music/tracks.json').writeAsStringSync(
File(
'${folder.path}/music/tracks.json',
).readAsStringSync().replaceFirst('"analysed":true', '"analysed":false'),
);
final again = MusicDownloads(
library,
Directory('${folder.path}/music'),
platform,
person: 'ada',
);
await again.sync();
expect(platform.job!.tracks, hasLength(1));
expect(platform.job!.tracks.single.analyse, isTrue);
await again.dispose();
});
test('an ID that could name another file is never one', () {

View file

@ -102,10 +102,12 @@ void main() {
'pw',
);
});
final platform = FakeMusicPlatform(jellyfin);
final kept = MusicDownloads(
library,
Directory('${folder.path}/music'),
FakeMusicPlatform(),
platform,
person: 'ada',
);
addTearDown(kept.dispose);
await tester.pumpWidget(
@ -126,6 +128,13 @@ void main() {
await tester.runAsync(kept.sync);
await tester.pump();
expect(find.text('On this phone'), findsOneWidget);
expect(find.text('Downloading: 0 of 2 tracks'), findsOneWidget);
await tester.runAsync(() async {
await platform.run(tokens.token!);
await kept.sync();
});
await tester.pump();
expect(find.text('2 tracks, 0.0 MB, 2 with a tempo'), findsOneWidget);
await tester.tap(find.text('Delete'));