// 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 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 j) => DownloadInfo( song: Song.fromJson((j['song'] as Map).cast()), 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 byId; DownloadInfo? operator [](String id) => byId[id]; bool isDownloaded(String id) => byId[id]?.isDone ?? false; List get completed => byId.values.where((d) => d.isDone).toList(); int get totalBytes => completed.fold(0, (sum, d) => sum + (d.sizeBytes ?? 0)); DownloadState copyWith({Map? byId}) => DownloadState(byId: byId ?? this.byId); } /// Downloads tracks to disk for offline playback. Files live under /// `/downloads//` and a `downloads_.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 { 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 _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 _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 _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 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 = {}; if (raw is List) { for (final e in raw.whereType()) { final info = DownloadInfo.fromJson(e.cast()); 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 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 _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 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.from(state.byId)..remove(songId); state = state.copyWith(byId: next); await _persist(); } /// Delete every download for the active server. Future 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 _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 (_) {} } }