mobile-music/lib/downloads/download_manager.dart
2026-08-16 12:46:30 -04:00

583 lines
20 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 {}, this.artById = const {}});
final Map<String, DownloadInfo> byId;
/// Cover-art id -> absolute path of the cached art file on disk. This is
/// in-memory only: it is *rebuilt* on load by scanning disk (see
/// [DownloadController.reloadForServer]) and is never written to the manifest.
/// Multiple songs sharing a cover-art id map to the same deduped file.
final Map<String, String> artById;
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();
/// Total bytes of downloaded *audio* only — cached cover art is intentionally
/// excluded (art is small and shared across tracks; counting it would make
/// per-track/total sizes misleading).
int get totalBytes => completed.fold(0, (sum, d) => sum + (d.sizeBytes ?? 0));
DownloadState copyWith({
Map<String, DownloadInfo>? byId,
Map<String, String>? artById,
}) =>
DownloadState(byId: byId ?? this.byId, artById: artById ?? this.artById);
}
/// 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'},
),
);
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;
}
/// Absolute path to the cached cover-art file for [coverArtId], or null if no
/// art has been cached for it. Read synchronously by offline browse / Now
/// Playing to show real artwork without a network round-trip. Parallel to
/// [localPathFor]; the caller passes the song's `coverArt` id directly.
String? localArtPathFor(String? coverArtId) {
if (coverArtId == null) return null;
return state.artById[coverArtId];
}
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;
}
/// Directory holding cached cover art for [key], created on demand. Sits
/// beside the audio files at `<downloadsDir>/art`. Art filenames are derived
/// from the cover-art id via [_sanitizeArtId] so tracks sharing an id share a
/// single file (dedup).
Future<Directory> _artDir(String key) async {
final base = await _downloadsDir(key);
final d = Directory('${base.path}/art');
if (!await d.exists()) await d.create(recursive: true);
return d;
}
/// Turn a Subsonic cover-art id into a filesystem-safe filename stem. Any
/// character outside `[A-Za-z0-9._-]` becomes `_`. This is a pure function of
/// the id, so the same id always resolves to the same file — that lets us
/// dedup shared art and reverse-derive art paths on load without persisting
/// them (see [reloadForServer]).
String _sanitizeArtId(String coverArtId) =>
coverArtId.replaceAll(RegExp(r'[^A-Za-z0-9._-]'), '_');
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;
}
}
// Rediscover cached cover art. The manifest never stores art paths, so we
// reverse-derive them from each completed song's `coverArt` id + the
// [_sanitizeArtId] scheme and keep only ids whose file actually exists.
final artById = <String, String>{};
final artDir = await _artDir(key);
for (final info in byId.values) {
final coverArt = info.song.coverArt;
if (coverArt == null || artById.containsKey(coverArt)) continue;
final artPath = '${artDir.path}/${_sanitizeArtId(coverArt)}.jpg';
if (await File(artPath).exists()) artById[coverArt] = artPath;
}
if (gen == _generation) {
state = DownloadState(byId: byId, artById: artById);
// 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);
}
}
/// Re-run the pump — used when the concurrency setting is raised so queued
/// tracks start immediately instead of waiting for the next enqueue/finish.
void onConcurrencyChanged() => _pump();
void _pump() {
// Re-read the concurrency cap each pump so a settings change takes effect
// mid-session: raising it starts more downloads immediately; lowering it
// stops launching new ones while in-flight downloads drain naturally.
final maxConcurrent = AppSettings.clampConcurrentDownloads(
_settingsGetter().maxConcurrentDownloads,
);
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();
// Cache the cover art too, so offline browse / Now Playing can show real
// artwork. This is strictly best-effort and runs *after* the audio is
// already committed: any failure here is swallowed and must never fail or
// regress the (successful) audio download.
await _cacheArt(song, client, key, gen);
} catch (e) {
if (gen != _generation) return;
final cur = state.byId[song.id];
if (cur != null) {
_put(cur.copyWith(status: DownloadStatus.failed, error: e.toString()));
}
}
}
/// Best-effort fetch of [song]'s cover art to `<artDir>/<sanitizedId>.jpg`,
/// mirroring the audio temp+rename pattern. Soft-fails: any error is logged
/// only by being swallowed — the audio download stays `done` regardless.
/// Skips the network entirely if the art file already exists (dedup by
/// cover-art id) and honors the [gen] guard so a server switch mid-fetch
/// discards the partial file instead of surfacing another server's art.
Future<void> _cacheArt(
Song song,
SubsonicClient client,
String key,
int gen,
) async {
final coverArt = song.coverArt;
if (coverArt == null) return;
try {
final artDir = await _artDir(key);
final finalPath = '${artDir.path}/${_sanitizeArtId(coverArt)}.jpg';
if (await File(finalPath).exists()) {
// Already cached (possibly by a sibling track) — just ensure the map
// reflects it and skip the fetch.
if (gen == _generation && state.artById[coverArt] != finalPath) {
state = state.copyWith(
artById: {...state.artById, coverArt: finalPath},
);
}
return;
}
final tmpPath = '$finalPath.part';
await _dio.downloadUri(client.coverArtUri(coverArt, size: 512), tmpPath);
if (gen != _generation) {
// Server switched mid-fetch — discard the partial art file.
await File(tmpPath).delete().catchError((_) => File(tmpPath));
return;
}
await File(tmpPath).rename(finalPath);
// Surface the new art immediately to the UI.
if (gen == _generation) {
state = state.copyWith(
artById: {...state.artById, coverArt: finalPath},
);
}
} catch (_) {
// Art is optional; never fail the audio download because of it.
}
}
// ---- 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);
// Drop the cached art too, but only if no *other* remaining completed
// download still references the same cover-art id (art is shared/deduped).
var nextArt = state.artById;
final coverArt = info.song.coverArt;
if (coverArt != null) {
final stillUsed = next.values.any(
(d) => d.isDone && d.song.coverArt == coverArt,
);
if (!stillUsed) {
final artPath = state.artById[coverArt];
if (artPath != null) {
try {
final f = File(artPath);
if (await f.exists()) await f.delete();
} catch (_) {}
}
nextArt = Map<String, String>.from(state.artById)..remove(coverArt);
}
}
state = state.copyWith(byId: next, artById: nextArt);
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 (_) {}
}
}
// Wipe the whole art directory in one shot (resets artById implicitly via
// the `const DownloadState()` assignment below).
if (key != null) {
try {
final artDir = await _artDir(key);
if (await artDir.exists()) await artDir.delete(recursive: true);
} 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 (_) {}
}
}