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
5 changed files with 830 additions and 207 deletions

View file

@ -436,6 +436,7 @@ Future<TrainPerson> _openPersonData(
music,
Directory(p.join(folder.path, 'music')),
AndroidMusicPlatform(),
person: person.authUserId,
)..open());
final resumed = keptMusic == null
? null

View file

@ -264,50 +264,13 @@ class JellyfinClient(
];
}
/// Downloads the track [id] as Jellyfin keeps it into [file], through a
/// part file renamed once whole; its size.
Future<int> download(
Uri address,
JellyfinSession session,
String id,
File file,
) async {
final request = http.Request(
'GET',
address.replace(
path: '${address.path}/Audio/$id/stream',
queryParameters: {'static': 'true'},
),
)..headers['authorization'] = _authorization(session.token);
final part = File('${file.path}.part');
try {
final response = await _http.send(request).timeout(_timeout);
if (response.statusCode == 401 || response.statusCode == 403) {
throw const JellyfinException(JellyfinFailure.refused);
}
if (response.statusCode >= 300) {
throw const JellyfinException(JellyfinFailure.failed);
}
file.parent.createSync(recursive: true);
final sink = part.openWrite();
try {
// A connection that stalls for the timeout gives up.
await sink.addStream(response.stream.timeout(_timeout));
} finally {
await sink.close();
}
part.renameSync(file.path);
return file.lengthSync();
} on Object catch (error) {
if (part.existsSync()) {
part.deleteSync();
}
if (_unreachable(error)) {
throw const JellyfinException(JellyfinFailure.unreachable);
}
rethrow;
}
}
/// 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';
/// The `Authorization` header Train asks with, without a token: the
/// music job adds the token it keeps (D52).
String get clientAuthorization => _authorization(null);
/// Ends [session]'s token on the server; what fails is left, as the
/// device forgets the token anyway.

View file

@ -5,7 +5,6 @@ import 'dart:io';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
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';
@ -18,7 +17,8 @@ final class const KeptMusic({
/// Every track of the marked playlists, by its ID, downloaded or not.
final Map<String, MusicTrack> tracks = const {},
/// Whether a download is under way.
/// Whether the playlists are being read, or the music job fetches what
/// they hold.
final bool loading = false,
/// Whether downloads wait for an unmetered network.
@ -48,16 +48,22 @@ final class const KeptMusic({
List<Object?> get props => [tracks, loading, waiting];
}
/// Keeps the marked playlists' tracks on this phone (D47): their list read
/// from Jellyfin, each track downloaded as Jellyfin keeps it on unmetered
/// networks only, its tempo worked out once ([MusicPlatform.tempo]); the
/// tracks no marked playlist holds any more deleted, offline too. Files in
/// [_folder] by their IDs, the list in its `tracks.json`.
/// Keeps the marked playlists' tracks on this phone (D47, D52): their list
/// read from Jellyfin as the app opens and resumes, the missing ones handed
/// to the music job ([MusicPlatform.schedule]), which downloads them as
/// Jellyfin keeps them on unmetered networks, the app open or not, and
/// works out their tempo once; what it fetched taken in from [_folder]'s
/// `fetched.txt` as the app opens and as each comes. The tracks no marked
/// playlist holds any more are deleted, offline too. Files in [_folder] by
/// their IDs, the list in its `tracks.json`.
class MusicDownloads(
final MusicLibrary _library,
final Directory _folder,
final MusicPlatform _platform,
) {
final MusicPlatform _platform, {
/// Whose music it is, as the job finds their token by it.
required final String person,
}) {
late final ValueNotifier<KeptMusic> kept = ValueNotifier(
KeptMusic(tracks: _read()),
);
@ -69,6 +75,7 @@ class MusicDownloads(
);
var _closed = false;
StreamSubscription<void>? _fetched;
File get _list => File(p.join(_folder.path, 'tracks.json'));
@ -81,6 +88,7 @@ class MusicDownloads(
/// whenever a network may have come (the app resumed).
void open() {
_library.settings.addListener(_marksChanged);
_fetched = _platform.fetched.listen((_) => _takeIn());
unawaited(sync());
}
@ -90,6 +98,7 @@ class MusicDownloads(
Future<void> dispose() async {
_closed = true;
_library.settings.removeListener(_marksChanged);
await _fetched?.cancel();
kept.dispose();
}
@ -103,110 +112,162 @@ class MusicDownloads(
}
Future<void> _sync() async {
_takeIn();
final settings = _library.settings.value;
_marked = {...settings.playlists.keys};
// 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) {
await _schedule();
return;
}
if (!await _platform.unmetered()) {
_set(kept.value.copyWith(waiting: true));
await _schedule();
return;
}
_set(kept.value.copyWith(waiting: false, loading: true));
_set(kept.value.copyWith(loading: true));
try {
final listed = <String, MusicTrack>{};
final unread = <String>{};
for (final playlist in _marked) {
try {
for (final track in await _library.tracks(playlist)) {
final known = listed[track.id] ?? kept.value.tracks[track.id];
listed[track.id] = (known ?? track).copyWith(
name: track.name,
artist: () => track.artist,
album: () => track.album,
length: () => track.length,
playlists: {
if (listed[track.id] case final seen?) ...seen.playlists,
playlist,
},
);
}
} on JellyfinException catch (error) {
if (error.failure == JellyfinFailure.unreachable ||
error.failure == JellyfinFailure.refused) {
return;
}
// A playlist that can't be read keeps what it had.
unread.add(playlist);
}
}
for (final track in kept.value.tracks.values) {
final holding = track.playlists.where(unread.contains);
if (holding.isNotEmpty) {
listed[track.id] = switch (listed[track.id]) {
final read? => read.copyWith(
playlists: {...read.playlists, ...holding},
),
null => track,
};
}
}
// The files of what the playlists no longer hold go before the list
// forgets them.
_keepOnly((track) => listed.containsKey(track.id));
_set(kept.value.copyWith(tracks: listed));
await _download();
await _readPlaylists();
} finally {
if (!_closed) {
_set(kept.value.copyWith(loading: false));
}
await _schedule();
}
}
Future<void> _download() async {
for (final id in [...kept.value.tracks.keys]) {
final track = kept.value.tracks[id];
final file = fileOf(id);
if (track == null || file == null || _closed) {
continue;
}
if (!track.downloaded) {
if (!await _platform.unmetered()) {
_set(kept.value.copyWith(waiting: true));
/// Reads the marked playlists, keeping what a playlist that can't be
/// read had.
Future<void> _readPlaylists() async {
final listed = <String, MusicTrack>{};
final unread = <String>{};
for (final playlist in _marked) {
try {
for (final track in await _library.tracks(playlist)) {
final known = listed[track.id] ?? kept.value.tracks[track.id];
listed[track.id] = (known ?? track).copyWith(
name: track.name,
artist: () => track.artist,
album: () => track.album,
length: () => track.length,
playlists: {
if (listed[track.id] case final seen?) ...seen.playlists,
playlist,
},
);
}
} on JellyfinException catch (error) {
if (error.failure == JellyfinFailure.unreachable ||
error.failure == JellyfinFailure.refused) {
return;
}
final int bytes;
// A playlist that can't be read keeps what it had.
unread.add(playlist);
}
}
for (final track in kept.value.tracks.values) {
final holding = track.playlists.where(unread.contains);
if (holding.isNotEmpty) {
listed[track.id] = switch (listed[track.id]) {
final read? => read.copyWith(
playlists: {...read.playlists, ...holding},
),
null => track,
};
}
}
// The files of what the playlists no longer hold go before the list
// forgets them.
_keepOnly((track) => listed.containsKey(track.id));
_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.
List<MusicJobTrack> get _missing => [
for (final track in kept.value.tracks.values)
if (!track.downloaded || !track.analysed)
(
id: track.id,
path: JellyfinClient.audioPath(track.id),
analyse: !track.analysed,
),
];
/// Hands the job the missing tracks, and says whether they wait for an
/// unmetered network.
Future<void> _schedule() async {
if (_closed) {
return;
}
final settings = _library.settings.value;
final missing = _missing;
final address = settings.address;
if (!settings.signedIn || address == null || missing.isEmpty) {
await _platform.schedule(null);
_set(kept.value.copyWith(loading: false, waiting: false));
return;
}
await _platform.schedule(
MusicJob(
person: person,
folder: _folder.path,
address: address,
client: _library.jellyfin.clientAuthorization,
tracks: missing,
),
);
final unmetered = await _platform.unmetered();
_set(kept.value.copyWith(loading: unmetered, waiting: !unmetered));
}
/// Takes in what the job fetched: each line of `fetched.txt` for a track
/// still kept, the file of one no longer kept deleted.
void _takeIn() {
if (_closed) {
return;
}
final lines = File(p.join(_folder.path, 'fetched.txt'));
final taking = File(p.join(_folder.path, 'fetched.taking'));
try {
if (lines.existsSync() && !taking.existsSync()) {
lines.renameSync(taking.path);
}
if (!taking.existsSync()) {
return;
}
for (final line in taking.readAsLinesSync()) {
final Object? json;
try {
bytes = await _library.download(id, file);
} on JellyfinException catch (error) {
if (error.failure == JellyfinFailure.failed) {
// This track alone: the next sync tries it again.
continue;
json = jsonDecode(line);
} on FormatException {
continue;
}
if (json
case {
'id': final String id,
'bytes': final int bytes,
} &&
final Map<String, Object?> fetched) {
if (kept.value.tracks.containsKey(id)) {
_update(
id,
(track) => fetched['analysed'] == true
? track.copyWith(
bytes: () => bytes,
tempo: () => (fetched['tempo'] as num?)?.toDouble(),
analysed: true,
)
: track.copyWith(bytes: () => bytes),
);
} else {
_deleteFile(id);
}
return;
}
if (_closed) {
return;
}
_update(id, (track) => track.copyWith(bytes: () => bytes));
}
if (!track.analysed) {
double? tempo;
try {
tempo = await _platform.tempo(file.path);
} on PlatformException catch (error) {
log('Tempo not worked out: ${error.code}', name: 'Music');
}
if (_closed) {
return;
}
_update(
id,
(track) => track.copyWith(tempo: () => tempo, analysed: true),
);
}
taking.deleteSync();
} on FileSystemException catch (error) {
log('Fetched music not taken in: ${error.runtimeType}', name: 'Music');
}
if (_missing.isEmpty && kept.value.loading) {
_set(kept.value.copyWith(loading: false));
}
}
@ -228,13 +289,7 @@ class MusicDownloads(
if (gone.isEmpty) {
return;
}
for (final id in gone) {
try {
fileOf(id)?.deleteSync();
} on PathNotFoundException {
// Never downloaded.
}
}
gone.forEach(_deleteFile);
_set(
kept.value.copyWith(
tracks: {
@ -245,6 +300,14 @@ class MusicDownloads(
);
}
void _deleteFile(String id) {
try {
fileOf(id)?.deleteSync();
} on PathNotFoundException {
// Never downloaded.
}
}
Map<String, MusicTrack> _read() {
try {
final json = jsonDecode(_list.readAsStringSync());

View file

@ -1,5 +1,3 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/jellyfin_client.dart';
import 'package:halkyon_train_flutter/src/feature/music/data/music_store.dart';
@ -70,11 +68,6 @@ class MusicLibrary(
(address, session) => jellyfin.tracks(address, session, playlist),
);
/// Downloads the track [id] into [file]; its size.
Future<int> download(String id, File file) => _asking(
(address, session) => jellyfin.download(address, session, id, file),
);
/// What [ask] answers as the person signed in.
Future<T> _asking<T>(
Future<T> Function(Uri address, JellyfinSession session) ask,

View file

@ -1,26 +1,74 @@
import 'dart:convert';
import 'package:equatable/equatable.dart';
import 'package:flutter/services.dart';
/// The phone's side of music (ADR 0030, D47).
/// 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});
/// 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
/// them.
final class const MusicJob({
required final String person,
required final String folder,
required final Uri address,
/// Jellyfin's `Authorization` header as Train makes it, without the
/// token, which the job adds.
required final String client,
required final List<MusicJobTrack> tracks,
}) with Equatable {
Map<String, Object?> toJson() => {
'person': person,
'folder': folder,
'address': address.toString(),
'client': client,
'tracks': [
for (final track in tracks)
{'id': track.id, 'path': track.path, 'analyse': track.analyse},
],
};
@override
List<Object?> get props => [person, folder, address, client, tracks];
}
/// The phone's side of music (ADR 0030, D47, D52).
abstract interface class MusicPlatform() {
/// Whether the network is unmetered, as downloads wait for one.
Future<bool> unmetered();
/// The beats per minute of the track in the file at [path]; null where
/// neither its tag nor its beat says one.
Future<double?> tempo(String path);
/// Has the music job fetch [job]'s tracks, now and every few hours while
/// some are missing; nothing more where it is null.
Future<void> schedule(MusicJob? job);
/// Each track the job fetched, while the app is open: a line more in the
/// music folder's `fetched.txt`.
Stream<void> get fetched;
}
/// [MusicPlatform] on Android, over `app.halkyon.train/music`
/// (`MusicBridge.kt`): the tag `BpmTag.kt` reads, else `Tempo.kt`'s own
/// analysis of a minute from the track's middle.
/// [MusicPlatform] on Android, over `app.halkyon.train/music` and
/// `…/music/fetched` (`MusicBridge.kt`, `MusicWorker.kt`: a WorkManager job
/// on unmetered networks, its tempo the tag `BpmTag.kt` reads, else
/// `Tempo.kt`'s own analysis of a minute from the track's middle).
class AndroidMusicPlatform({
final MethodChannel _channel = const MethodChannel('app.halkyon.train/music'),
final EventChannel _fetched = const EventChannel(
'app.halkyon.train/music/fetched',
),
}) implements MusicPlatform {
@override
Future<bool> unmetered() async =>
await _channel.invokeMethod<bool>('unmetered') ?? false;
@override
Future<double?> tempo(String path) =>
_channel.invokeMethod<double>('tempo', {'path': path});
Future<void> schedule(MusicJob? job) => _channel.invokeMethod<void>(
'schedule',
{'job': job == null ? null : jsonEncode(job.toJson())},
);
@override
Stream<void> get fetched => _fetched.receiveBroadcastStream();
}