mirror of
https://github.com/austinried/subtracks.git
synced 2025-12-27 00:59:28 +01:00
590 lines
16 KiB
Dart
590 lines
16 KiB
Dart
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<Download> downloads,
|
|
@Default(IListConst([])) IList<SourceId> deletes,
|
|
@Default(IListConst([])) IList<SourceId> listDownloads,
|
|
@Default(IListConst([])) IList<SourceId> 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<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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<void> deleteAll(int sourceId) async {
|
|
final db = ref.read(databaseProvider);
|
|
|
|
final albumIds = await db.albumIdsWithDownloaded(sourceId).get();
|
|
for (var id in albumIds) {
|
|
await deleteAlbum(await (db.albumById(sourceId, id)).getSingle());
|
|
}
|
|
}
|
|
|
|
Future<void> _downloadList(
|
|
SourceIdentifiable list,
|
|
Future<void> 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<void> _cancelList(
|
|
SourceIdentifiable list,
|
|
Future<void> 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<void> _deleteList(
|
|
SourceIdentifiable list,
|
|
Future<void> 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<void> _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<void> _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<void> _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<FilterWith> _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<List<Song>> _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<List<Song>> _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<void> _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<bool> _tryDeleteFile(String filePath) async {
|
|
try {
|
|
final file = File(filePath);
|
|
await file.delete();
|
|
return true;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<void> _deleteTasksNotIn(List<String> taskIds) async {
|
|
if (taskIds.isEmpty) {
|
|
return;
|
|
}
|
|
await FlutterDownloader.loadTasksWithRawQuery(
|
|
query: 'DELETE FROM task WHERE task_id NOT IN '
|
|
'(${taskIds.map((e) => "'$e'").join(',')})',
|
|
);
|
|
}
|
|
|
|
Future<void> _deleteTask(String taskId) async {
|
|
await FlutterDownloader.loadTasksWithRawQuery(
|
|
query: 'DELETE FROM task WHERE task_id = \'$taskId\'',
|
|
);
|
|
}
|
|
|
|
Future<DownloadTask?> _getTask(String taskId) async {
|
|
return (await FlutterDownloader.loadTasksWithRawQuery(
|
|
query: 'SELECT * FROM task WHERE task_id = \'$taskId\'',
|
|
))
|
|
?.firstOrNull;
|
|
}
|
|
|
|
Future<void> _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<void> _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<dynamic>)[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],
|
|
);
|
|
}
|
|
}
|