mobile-music/lib/downloads/download_manager.dart
2026-07-31 21:06:19 -04:00

427 lines
14 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Callback fields are assigned from named required params, which can't be
// private initializing formals.
// ignore_for_file: prefer_initializing_formals
import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import '../settings/settings_store.dart';
import '../subsonic/models.dart';
import '../subsonic/subsonic_client.dart';
/// Lifecycle of a single track download.
enum DownloadStatus { queued, downloading, done, failed }
/// Per-song download record. Only [DownloadStatus.done] entries are persisted
/// to the manifest; queued/downloading/failed entries are in-memory only.
class DownloadInfo {
const DownloadInfo({
required this.song,
required this.status,
this.path,
this.bitRate,
this.format,
this.sizeBytes,
this.progress = 0,
this.error,
});
final Song song;
final DownloadStatus status;
/// Absolute path to the downloaded file (set once [status] is done).
final String? path;
final int? bitRate;
final String? format;
final int? sizeBytes;
/// 0.0–1.0 while downloading (0 if the total size is unknown mid-flight).
final double progress;
final String? error;
bool get isDone => status == DownloadStatus.done;
bool get isActive =>
status == DownloadStatus.queued || status == DownloadStatus.downloading;
DownloadInfo copyWith({
DownloadStatus? status,
String? path,
int? bitRate,
String? format,
int? sizeBytes,
double? progress,
String? error,
}) =>
DownloadInfo(
song: song,
status: status ?? this.status,
path: path ?? this.path,
bitRate: bitRate ?? this.bitRate,
format: format ?? this.format,
sizeBytes: sizeBytes ?? this.sizeBytes,
progress: progress ?? this.progress,
error: error,
);
/// Serialize a completed record. The path is written as *relative* to the
/// app-support directory by [DownloadController._persist] (key `relPath`) —
/// absolute paths embed the iOS app-container UUID, which changes across app
/// updates and would orphan every download. See [DownloadController].
Map<String, dynamic> toJson() => {
'song': song.toJson(),
if (bitRate != null) 'bitRate': bitRate,
if (format != null) 'format': format,
if (sizeBytes != null) 'sizeBytes': sizeBytes,
};
/// Rebuild a completed record from the manifest. [path] is the absolute path
/// resolved by the controller from the stored relative (or legacy absolute)
/// path against the *current* app-support directory.
factory DownloadInfo.fromJson(Map<String, dynamic> j, {String? path}) =>
DownloadInfo(
song: Song.fromJson((j['song'] as Map).cast<String, dynamic>()),
status: DownloadStatus.done,
path: path,
bitRate: (j['bitRate'] as num?)?.toInt(),
format: j['format'] as String?,
sizeBytes: (j['sizeBytes'] as num?)?.toInt(),
progress: 1,
);
}
/// Snapshot of all known downloads for the active server, keyed by song id.
class DownloadState {
const DownloadState({this.byId = const {}});
final Map<String, DownloadInfo> byId;
DownloadInfo? operator [](String id) => byId[id];
bool isDownloaded(String id) => byId[id]?.isDone ?? false;
List<DownloadInfo> get completed =>
byId.values.where((d) => d.isDone).toList();
int get totalBytes => completed.fold(0, (sum, d) => sum + (d.sizeBytes ?? 0));
DownloadState copyWith({Map<String, DownloadInfo>? byId}) =>
DownloadState(byId: byId ?? this.byId);
}
/// Downloads tracks to disk for offline playback. Files live under
/// `<appSupport>/downloads/<serverKey>/` and a `downloads_<serverKey>.json`
/// manifest survives restarts. Modeled on `library/library_index.dart`
/// (per-server keying, atomic temp+rename writes, a generation guard so a
/// server switch mid-download can't clobber the new server's manifest).
class DownloadController extends StateNotifier<DownloadState> {
DownloadController({
required SubsonicClient? Function() clientGetter,
required AppSettings Function() settingsGetter,
required String? Function() serverKeyGetter,
}) : _clientGetter = clientGetter,
_settingsGetter = settingsGetter,
_serverKeyGetter = serverKeyGetter,
super(const DownloadState()) {
reloadForServer();
}
final SubsonicClient? Function() _clientGetter;
final AppSettings Function() _settingsGetter;
final String? Function() _serverKeyGetter;
final Dio _dio = Dio(BaseOptions(
receiveTimeout: const Duration(minutes: 5),
headers: {'User-Agent': 'timbre'},
));
static const int _maxConcurrent = 3;
int _generation = 0;
String? _loadedKey;
final List<String> _queue = [];
int _active = 0;
/// Cached app-support directory path. Manifests store paths *relative* to
/// this so downloads survive the app-container path changing across updates;
/// we re-root them against the current directory at load time.
String? _supportDirPath;
Future<String> _supportPath() async =>
_supportDirPath ??= (await getApplicationSupportDirectory()).path;
/// Strip the app-support prefix so the manifest stores a stable relative path
/// (`downloads/<key>/<file>`). Falls back to the `downloads/` segment if the
/// absolute path doesn't sit under the cached base.
String? _relativize(String? absPath) {
if (absPath == null) return null;
final base = _supportDirPath;
if (base != null && absPath.startsWith('$base/')) {
return absPath.substring(base.length + 1);
}
final i = absPath.indexOf('downloads/');
return i >= 0 ? absPath.substring(i) : absPath;
}
/// Resolve a manifest entry's stored path to an absolute path under the
/// *current* app-support directory. Prefers the new relative `relPath`; for a
/// legacy absolute `path` it re-roots the trailing `downloads/...` segment so
/// downloads made before this change (or before an app update) still resolve.
String? _resolveStoredPath(Map<String, dynamic> j) {
final base = _supportDirPath;
final rel = j['relPath'] as String?;
if (rel != null) return base != null ? '$base/$rel' : rel;
final legacy = j['path'] as String?;
if (legacy == null) return null;
final i = legacy.indexOf('downloads/');
if (i >= 0 && base != null) return '$base/${legacy.substring(i)}';
return legacy;
}
/// Path to a downloaded file if (and only if) it is fully downloaded — read
/// synchronously by the playback stream-URI resolver.
String? localPathFor(String id) {
final info = state.byId[id];
return info != null && info.isDone ? info.path : null;
}
bool isDownloaded(String id) => state.isDownloaded(id);
// ---- Server switching / manifest load ----------------------------------
Future<Directory> _downloadsDir(String key) async {
final dir = await getApplicationSupportDirectory();
final d = Directory('${dir.path}/downloads/$key');
if (!await d.exists()) await d.create(recursive: true);
return d;
}
Future<File> _manifestFile(String key) async {
final dir = await getApplicationSupportDirectory();
return File('${dir.path}/downloads_$key.json');
}
/// Load (or clear) the manifest when the active server changes. Verifies each
/// file still exists on disk and drops stale entries.
Future<void> reloadForServer() async {
final key = _serverKeyGetter();
if (key == _loadedKey) return;
_generation++;
final gen = _generation;
_loadedKey = key;
_queue.clear();
if (key == null) {
state = const DownloadState();
return;
}
await _supportPath();
try {
final file = await _manifestFile(key);
if (!await file.exists()) {
if (gen == _generation) state = const DownloadState();
return;
}
final raw = jsonDecode(await file.readAsString());
final byId = <String, DownloadInfo>{};
var migrated = false;
if (raw is List) {
for (final e in raw.whereType<Map>()) {
final map = e.cast<String, dynamic>();
final path = _resolveStoredPath(map);
if (path == null || !await File(path).exists()) continue;
final info = DownloadInfo.fromJson(map, path: path);
byId[info.song.id] = info;
// A legacy absolute-path entry re-persists as relative on next save.
if (map['relPath'] == null) migrated = true;
}
}
if (gen == _generation) {
state = DownloadState(byId: byId);
// Self-migrate the manifest to relative paths.
if (migrated) await _persist();
}
} catch (_) {
if (gen == _generation) state = const DownloadState();
}
}
// ---- Enqueue / download -------------------------------------------------
/// Queue [song] for download (no-op if already downloaded or in flight).
void download(Song song) {
if (song.id.isEmpty) return;
final existing = state.byId[song.id];
if (existing != null && (existing.isDone || existing.isActive)) return;
if (_clientGetter() == null) return; // offline — nothing to fetch.
_put(DownloadInfo(song: song, status: DownloadStatus.queued));
_queue.add(song.id);
_pump();
}
/// Queue every track (album / playlist "download all").
void downloadAll(List<Song> songs) {
for (final s in songs) {
download(s);
}
}
void _pump() {
while (_active < _maxConcurrent && _queue.isNotEmpty) {
final id = _queue.removeAt(0);
final info = state.byId[id];
if (info == null || info.status != DownloadStatus.queued) continue;
_active++;
_run(info.song).whenComplete(() {
_active--;
_pump();
});
}
}
Future<void> _run(Song song) async {
final gen = _generation;
final client = _clientGetter();
final key = _serverKeyGetter();
if (client == null || key == null) {
_put(state.byId[song.id]!
.copyWith(status: DownloadStatus.failed, error: 'Not connected'));
return;
}
final settings = _settingsGetter();
final rate = settings.downloadMaxBitRate;
final format = settings.downloadFormat;
final uri = client.streamUri(song.id, maxBitRate: rate, format: format);
// Choose a sensible extension; the stored path is authoritative regardless.
final ext = format ?? (rate > 0 ? 'mp3' : (song.suffix ?? 'mp3'));
try {
_put(state.byId[song.id]!
.copyWith(status: DownloadStatus.downloading, progress: 0));
final dir = await _downloadsDir(key);
final finalPath = '${dir.path}/${song.id}.$ext';
final tmpPath = '$finalPath.part';
await _dio.downloadUri(
uri,
tmpPath,
onReceiveProgress: (received, total) {
if (gen != _generation) return;
if (total > 0) {
final cur = state.byId[song.id];
if (cur != null && cur.status == DownloadStatus.downloading) {
_put(cur.copyWith(progress: received / total));
}
}
},
);
if (gen != _generation) {
// Server switched mid-download — discard the partial file.
await File(tmpPath).delete().catchError((_) => File(tmpPath));
return;
}
// Honor a removal that happened mid-download: don't resurrect the entry.
if (!state.byId.containsKey(song.id)) {
await File(tmpPath).delete().catchError((_) => File(tmpPath));
return;
}
final tmp = File(tmpPath);
await tmp.rename(finalPath);
final size = await File(finalPath).length();
_put(DownloadInfo(
song: song,
status: DownloadStatus.done,
path: finalPath,
bitRate: rate == 0 ? null : rate,
format: format,
sizeBytes: size,
progress: 1,
));
await _persist();
} catch (e) {
if (gen != _generation) return;
final cur = state.byId[song.id];
if (cur != null) {
_put(cur.copyWith(status: DownloadStatus.failed, error: e.toString()));
}
}
}
// ---- Remove -------------------------------------------------------------
/// Delete a single download (file + manifest entry).
Future<void> remove(String songId) async {
final info = state.byId[songId];
if (info == null) return;
_queue.remove(songId);
if (info.path != null) {
try {
final f = File(info.path!);
if (await f.exists()) await f.delete();
} catch (_) {}
}
final next = Map<String, DownloadInfo>.from(state.byId)..remove(songId);
state = state.copyWith(byId: next);
await _persist();
}
/// Delete every download for the active server.
Future<void> clearAll() async {
_queue.clear();
final key = _serverKeyGetter();
for (final info in state.byId.values) {
if (info.path != null) {
try {
final f = File(info.path!);
if (await f.exists()) await f.delete();
} catch (_) {}
}
}
state = const DownloadState();
if (key != null) {
try {
final file = await _manifestFile(key);
if (await file.exists()) await file.delete();
} catch (_) {}
}
}
// ---- Internals ----------------------------------------------------------
void _put(DownloadInfo info) {
state = state.copyWith(
byId: {...state.byId, info.song.id: info},
);
}
/// Atomic write of the completed-downloads manifest (temp + rename).
Future<void> _persist() async {
final key = _serverKeyGetter();
if (key == null) return;
try {
await _supportPath();
final file = await _manifestFile(key);
final tmp = File('${file.path}.tmp');
await tmp.writeAsString(
jsonEncode(state.completed.map((d) {
final j = d.toJson();
final rel = _relativize(d.path);
if (rel != null) j['relPath'] = rel;
return j;
}).toList()),
);
await tmp.rename(file.path);
} catch (_) {}
}
}