import 'dart:io'; import 'dart:isolate'; import 'dart:ui'; import 'package:collection/collection.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter_downloader/flutter_downloader.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:mime/mime.dart'; import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../database/database.dart'; import '../http/client.dart'; import '../models/music.dart'; import '../models/query.dart'; import '../models/support.dart'; import '../state/music.dart'; import '../state/settings.dart'; import 'cache_service.dart'; part 'download_service.freezed.dart'; part 'download_service.g.dart'; @freezed class DownloadState with _$DownloadState { const factory DownloadState({ @Default(IListConst([])) IList downloads, @Default(IListConst([])) IList deletes, @Default(IListConst([])) IList listDownloads, @Default(IListConst([])) IList listCancels, required String saveDir, }) = _DownloadState; } @freezed class Download with _$Download { const Download._(); const factory Download({ required String taskId, required DownloadTaskStatus status, required int progress, required String url, required String? filename, required String savedDir, required int timeCreated, required bool allowCellular, }) = _Download; factory Download.fromTask(DownloadTask task) { return Download( taskId: task.taskId, status: task.status, progress: task.progress, url: task.url, filename: task.filename, savedDir: task.savedDir, timeCreated: task.timeCreated, allowCellular: task.allowCellular, ); } DownloadTask toTask() { return DownloadTask( taskId: taskId, status: status, progress: progress, url: url, filename: filename, savedDir: savedDir, timeCreated: timeCreated, allowCellular: allowCellular, ); } } enum SongDownloadState { none, inProgress, completed, } @Riverpod(keepAlive: true) class DownloadService extends _$DownloadService { static final ReceivePort _port = ReceivePort(); @override DownloadState build() { return const DownloadState(saveDir: ''); } Future init() async { await FlutterDownloader.initialize( // debug: true, ignoreSsl: true, ); state = state.copyWith( saveDir: path.join( (await getApplicationDocumentsDirectory()).path, 'downloads', ), ); _bindBackgroundIsolate(); await _syncDownloadTasks(); FlutterDownloader.registerCallback(downloadCallback, step: 1); } Future downloadAlbum(Album album) async { return _downloadList(album, () async { final cache = ref.read(cacheServiceProvider); await Future.wait([ _cacheArtistArt(album.sourceId, album.artistId, false), _cacheArtistArt(album.sourceId, album.artistId, true), _cacheImage(cache.albumArt(album, thumbnail: false)), _cacheImage(cache.albumArt(album, thumbnail: true)), ]); if (_isCanceled(album)) return; final songs = await _albumSongs(album, SongDownloadState.none); if (_isCanceled(album)) return; for (var song in songs) { await _downloadSong(song); if (_isCanceled(album)) return; } }); } Future downloadPlaylist(Playlist playlist) async { return _downloadList(playlist, () async { final songs = await _playlistSongs(playlist, SongDownloadState.none); if (_isCanceled(playlist)) return; final albumIds = songs.map((e) => e.albumId).whereNotNull().toSet(); final albums = await ref.read(albumsInIdsProvider(albumIds.toIList()).future); final artistIds = albums.map((e) => e.artistId).whereNotNull().toSet(); final cache = ref.read(cacheServiceProvider); await Future.wait([ _cacheImage(cache.playlistArt(playlist, thumbnail: true)), _cacheImage(cache.playlistArt(playlist, thumbnail: false)), ...albums.map((a) => _cacheImage(cache.albumArt(a, thumbnail: true))), ...albums.map((a) => _cacheImage(cache.albumArt(a, thumbnail: false))), ...artistIds.map( (artistId) => _cacheArtistArt(playlist.sourceId, artistId, true), ), ...artistIds.map( (artistId) => _cacheArtistArt(playlist.sourceId, artistId, false), ), ]); if (_isCanceled(playlist)) return; for (var song in songs) { await _downloadSong(song); if (_isCanceled(playlist)) return; } }); } Future cancelAlbum(Album album) async { return _cancelList(album, () async { final songs = await _albumSongs(album, SongDownloadState.inProgress); for (var song in songs) { try { await FlutterDownloader.cancel(taskId: song.downloadTaskId!); } catch (e) { // } } }); } Future cancelPlaylist(Playlist playlist) async { return _cancelList(playlist, () async { final songs = await _playlistSongs(playlist, SongDownloadState.inProgress); for (var song in songs) { await FlutterDownloader.cancel(taskId: song.downloadTaskId!); } }); } Future deleteAlbum(Album album) async { return _deleteList(album, () async { final db = ref.read(databaseProvider); final songs = await _albumSongs(album, SongDownloadState.completed); for (var song in songs) { await _tryDeleteFile(song.downloadFilePath!); await db.deleteSongDownloadFile(song.sourceId, song.id); } }); } Future deletePlaylist(Playlist playlist) async { return _deleteList(playlist, () async { final db = ref.read(databaseProvider); final songs = await _playlistSongs(playlist, SongDownloadState.completed); for (var song in songs) { if (await _tryDeleteFile(song.downloadFilePath!)) { await db.deleteSongDownloadFile(song.sourceId, song.id); } } }); } Future deleteAll(int sourceId) async { final db = ref.read(databaseProvider); final albumIds = await db.albumIdsWithDownloadStatus(sourceId).get(); for (var id in albumIds) { await deleteAlbum(await (db.albumById(sourceId, id)).getSingle()); } } Future _downloadList( SourceIdentifiable list, Future Function() callback, ) async { final sourceId = SourceId.from(list); if (state.listDownloads.contains(sourceId)) { return; } state = state.copyWith(listDownloads: state.listDownloads.add(sourceId)); try { await callback(); } finally { state = state.copyWith( listDownloads: state.listDownloads.remove(sourceId), ); } } Future _cancelList( SourceIdentifiable list, Future Function() callback, ) async { final sourceId = SourceId.from(list); if (state.listCancels.contains(sourceId)) return; state = state.copyWith( listCancels: state.listCancels.add(sourceId), ); if (state.listDownloads.contains(sourceId)) { var tries = 0; while (tries < 60) { await Future.delayed(const Duration(seconds: 1)); if (!state.listDownloads.contains(sourceId)) { break; } } } try { await callback(); } finally { state = state.copyWith( listCancels: state.listCancels.remove(sourceId), ); } } Future _deleteList( SourceIdentifiable list, Future Function() callback, ) async { final sourceId = SourceId.from(list); if (state.deletes.contains(sourceId)) { return; } state = state.copyWith(deletes: state.deletes.add(sourceId)); try { await callback(); } finally { state = state.copyWith(deletes: state.deletes.remove(sourceId)); } } Future _downloadSong(Song song) async { if (song.downloadFilePath != null || song.downloadTaskId != null) { return; } final source = ref.read(musicSourceProvider); final db = ref.read(databaseProvider); final http = ref.read(httpClientProvider); final uri = source.downloadUri(song.id); final head = await http.head(uri); final contentType = head.headers['content-type']; if (contentType == null) { throw StateError('Bad HTTP response from HEAD during download'); } final mime = contentType.split(';').first.toLowerCase(); if (!mime.startsWith('audio') && !mime.startsWith('application')) { throw StateError('Download error: MIME-type $mime is not audio'); } String? ext = extensionFromMime(mime); if (ext == mime) { ext = null; } final saveDir = Directory( path.join(state.saveDir, song.sourceId.toString()), ); await saveDir.create(recursive: true); final taskId = await FlutterDownloader.enqueue( url: source.downloadUri(song.id).toString(), savedDir: saveDir.path, fileName: ext != null ? '${song.id}.$ext' : song.id, headers: subtracksHeaders, openFileFromNotification: false, showNotification: false, ); await db.updateSongDownloadTask(taskId, song.sourceId, song.id); } Future _cacheImage(UriCacheInfo cache) async { final cachedFile = await cache.cacheManager.getFileFromCache( cache.cacheKey, ignoreMemCache: true, ); if (cachedFile == null) { try { await cache.cacheManager.getSingleFile( cache.uri.toString(), key: cache.cacheKey, ); } catch (_) {} } } Future _cacheArtistArt( int sourceId, String? artistId, bool thumbnail, ) async { if (artistId == null) { return; } final cache = ref.read(cacheServiceProvider); try { final uri = await cache.artistArtUri(artistId, thumbnail: thumbnail); if (uri == null) { return; } await _cacheImage(UriCacheInfo( uri: uri, cacheKey: cache.artistArtCacheInfo(artistId, thumbnail: thumbnail).cacheKey, cacheManager: cache.imageCache, )); } catch (_) {} } bool _isCanceled(SourceIdentifiable item) { return state.listCancels.contains(SourceId.from(item)); } List _downloadFilters(SongDownloadState state) { switch (state) { case SongDownloadState.none: return [ const FilterWith.isNull(column: 'download_task_id'), const FilterWith.isNull(column: 'download_file_path'), ]; case SongDownloadState.completed: return [ const FilterWith.isNull(column: 'download_file_path', invert: true), ]; case SongDownloadState.inProgress: return [ const FilterWith.isNull(column: 'download_task_id', invert: true), ]; } } Future> _albumSongs( Album album, SongDownloadState state, ) { return ref .read(databaseProvider) .albumSongsList( SourceId.from(album), ListQuery( sort: const SortBy(column: 'disc, track'), filters: _downloadFilters(state).lock, ), ) .get(); } Future> _playlistSongs( Playlist playlist, SongDownloadState state, ) { return ref .read(databaseProvider) .playlistSongsList( SourceId.from(playlist), ListQuery( sort: const SortBy(column: 'playlist_songs.position'), filters: _downloadFilters(state).lock, ), ) .get(); } Future _syncDownloadTasks() async { final tasks = await FlutterDownloader.loadTasks() ?? []; final downloads = tasks.map((e) => Download.fromTask(e)).toIList(); state = state.copyWith(downloads: downloads); final db = ref.read(databaseProvider); final songs = await db.songsWithDownloadTasks().get(); await _deleteTasksNotIn(songs.map((e) => e.downloadTaskId!).toList()); final deleteTaskStatus = [ DownloadTaskStatus.canceled, DownloadTaskStatus.failed, DownloadTaskStatus.undefined, ]; for (var song in songs) { final download = downloads.firstWhereOrNull( (t) => t.taskId == song.downloadTaskId, ); if (download == null) { await db.clearSongDownloadTaskBySong(song.sourceId, song.id); continue; } if (deleteTaskStatus.anyIs(download.status)) { await _clearFailedDownload(download); } else if (download.status == DownloadTaskStatus.complete) { await _completeDownload(download); } } } Future _tryDeleteFile(String filePath) async { try { final file = File(filePath); await file.delete(); return true; } catch (_) { return false; } } Future _deleteTasksNotIn(List taskIds) async { if (taskIds.isEmpty) { return; } await FlutterDownloader.loadTasksWithRawQuery( query: 'DELETE FROM task WHERE task_id NOT IN ' '(${taskIds.map((e) => "'$e'").join(',')})', ); } Future _deleteTask(String taskId) async { await FlutterDownloader.loadTasksWithRawQuery( query: 'DELETE FROM task WHERE task_id = \'$taskId\'', ); } Future _getTask(String taskId) async { return (await FlutterDownloader.loadTasksWithRawQuery( query: 'SELECT * FROM task WHERE task_id = \'$taskId\'', )) ?.firstOrNull; } Future _completeDownload(Download download) async { final db = ref.read(databaseProvider); await db.completeSongDownload( path.join(download.savedDir, download.filename), download.taskId, ); await _deleteTask(download.taskId); state = state.copyWith( downloads: state.downloads.removeWhere( (d) => d.taskId == download.taskId, ), ); } Future _clearFailedDownload(Download download) async { final db = ref.read(databaseProvider); await db.clearSongDownloadTask(download.taskId); await _deleteTask(download.taskId); await _tryDeleteFile(path.join(download.savedDir, download.filename)); state = state.copyWith( downloads: state.downloads.removeWhere( (d) => d.taskId == download.taskId, ), ); } void _bindBackgroundIsolate([retry = 0]) { final isSuccess = IsolateNameServer.registerPortWithName( _port.sendPort, 'downloader_send_port', ); if (!isSuccess && retry < 100) { _unbindBackgroundIsolate(); _bindBackgroundIsolate(retry + 1); return; } else if (retry >= 100) { throw StateError('Could not bind background isolate for downloads'); } _port.asyncMap((dynamic data) async { final taskId = (data as List)[0] as String; final status = DownloadTaskStatus(data[1] as int); final progress = data[2] as int; var download = state.downloads.firstWhereOrNull( (task) => task.taskId == taskId, ); if (download == null) { final task = await _getTask(taskId); if (task == null) { return; } download = Download.fromTask(task); } download = download.copyWith(status: status, progress: progress); state = state.copyWith( downloads: state.downloads.replaceFirstWhere( (d) => d.taskId == taskId, (d) => download!, addIfNotFound: true, ), ); final deleteTaskStatus = [ DownloadTaskStatus.canceled, DownloadTaskStatus.failed, DownloadTaskStatus.undefined, ]; if (status == DownloadTaskStatus.complete) { await _completeDownload(download); } else if (deleteTaskStatus.anyIs(status)) { await _clearFailedDownload(download); } }).listen((_) {}); } void _unbindBackgroundIsolate() { IsolateNameServer.removePortNameMapping('downloader_send_port'); } @pragma('vm:entry-point') static void downloadCallback( String id, DownloadTaskStatus status, int progress, ) { IsolateNameServer.lookupPortByName('downloader_send_port')?.send( [id, status.value, progress], ); } }