583 lines
20 KiB
Dart
583 lines
20 KiB
Dart
// 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 (_) {}
|
||
}
|
||
}
|