// 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 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 j, {String? path}) => DownloadInfo( song: Song.fromJson((j['song'] as Map).cast()), 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 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 artById; DownloadInfo? operator [](String id) => byId[id]; bool isDownloaded(String id) => byId[id]?.isDone ?? false; List 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? byId, Map? artById, }) => DownloadState(byId: byId ?? this.byId, artById: artById ?? this.artById); } /// 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'}, ), ); int _generation = 0; String? _loadedKey; final List _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 _supportPath() async => _supportDirPath ??= (await getApplicationSupportDirectory()).path; /// Strip the app-support prefix so the manifest stores a stable relative path /// (`downloads//`). 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 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 _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 `/art`. Art filenames are derived /// from the cover-art id via [_sanitizeArtId] so tracks sharing an id share a /// single file (dedup). Future _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 _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; } 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 = {}; var migrated = false; if (raw is List) { for (final e in raw.whereType()) { final map = e.cast(); 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 = {}; 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 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 _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 `/.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 _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 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); // 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.from(state.artById)..remove(coverArt); } } state = state.copyWith(byId: next, artById: nextArt); 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 (_) {} } } // 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 _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 (_) {} } }