mobile-music/lib/downloads/download_manager.dart
2026-07-29 14:14:18 -04:00

365 lines
11 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,
);
Map<String, dynamic> toJson() => {
'song': song.toJson(),
'path': path,
if (bitRate != null) 'bitRate': bitRate,
if (format != null) 'format': format,
if (sizeBytes != null) 'sizeBytes': sizeBytes,
};
/// Rebuild a completed record from the manifest.
factory DownloadInfo.fromJson(Map<String, dynamic> j) => DownloadInfo(
song: Song.fromJson((j['song'] as Map).cast<String, dynamic>()),
status: DownloadStatus.done,
path: j['path'] as String?,
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': 'ratune-mobile'},
));
static const int _maxConcurrent = 3;
int _generation = 0;
String? _loadedKey;
final List<String> _queue = [];
int _active = 0;
/// 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;
}
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>{};
if (raw is List) {
for (final e in raw.whereType<Map>()) {
final info = DownloadInfo.fromJson(e.cast<String, dynamic>());
final path = info.path;
if (path != null && await File(path).exists()) {
byId[info.song.id] = info;
}
}
}
if (gen == _generation) state = DownloadState(byId: byId);
} 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;
}
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 {
final file = await _manifestFile(key);
final tmp = File('${file.path}.tmp');
await tmp.writeAsString(
jsonEncode(state.completed.map((d) => d.toJson()).toList()),
);
await tmp.rename(file.path);
} catch (_) {}
}
}