From f0f0abd7820ea61e76510c3141d9bc4a6d61ef8b Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 6 Aug 2023 10:34:56 +0600 Subject: [PATCH] feat: concurrent download service & download prorvider --- CONTRIBUTION.md | 4 +- lib/components/library/user_downloads.dart | 113 +++-- lib/components/player/player_actions.dart | 13 +- lib/components/root/sidebar.dart | 2 +- .../root/spotube_navigation_bar.dart | 2 +- .../shared/track_table/track_options.dart | 28 +- .../shared/track_table/tracks_table_view.dart | 2 +- lib/pages/library/library.dart | 2 +- lib/pages/root/root_app.dart | 2 +- lib/pages/settings/settings.dart | 25 +- lib/provider/download_manager_provider.dart | 361 ++++++++------- .../download_manager/download_manager.dart | 413 ++++++++++++++++++ .../download_manager/download_request.dart | 24 + .../download_manager/download_status.dart | 26 ++ .../download_manager/download_task.dart | 36 ++ pubspec.lock | 14 +- pubspec.yaml | 2 +- 17 files changed, 843 insertions(+), 226 deletions(-) create mode 100644 lib/services/download_manager/download_manager.dart create mode 100644 lib/services/download_manager/download_request.dart create mode 100644 lib/services/download_manager/download_status.dart create mode 100644 lib/services/download_manager/download_task.dart diff --git a/CONTRIBUTION.md b/CONTRIBUTION.md index c9526640..b4b52084 100644 --- a/CONTRIBUTION.md +++ b/CONTRIBUTION.md @@ -122,12 +122,12 @@ Do the following: - Install Development dependencies in linux - Debian (>=12/Bookworm)/Ubuntu ```bash - $ apt-get install mpv libmpv-dev libappindicator3-1 gir1.2-appindicator3-0.1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev + $ apt-get install mpv libmpv-dev libappindicator3-1 gir1.2-appindicator3-0.1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev network-manager ``` - Use `libjsoncpp1` instead of `libjsoncpp25` (for Ubuntu < 22.04) - Arch/Manjaro ```bash - yay -S mpv libappindicator-gtk3 libsecret jsoncpp libnotify + yay -S mpv libappindicator-gtk3 libsecret jsoncpp libnotify networkmanager ``` - Fedora ```bash diff --git a/lib/components/library/user_downloads.dart b/lib/components/library/user_downloads.dart index eedd7840..f72f9f06 100644 --- a/lib/components/library/user_downloads.dart +++ b/lib/components/library/user_downloads.dart @@ -1,6 +1,4 @@ import 'package:auto_size_text/auto_size_text.dart'; -import 'package:background_downloader/background_downloader.dart'; -// import 'package:background_downloader/background_downloader.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -10,6 +8,7 @@ import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/download_manager_provider.dart'; +import 'package:spotube/services/download_manager/download_manager.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; class UserDownloads extends HookConsumerWidget { @@ -31,7 +30,7 @@ class UserDownloads extends HookConsumerWidget { Expanded( child: AutoSizeText( context.l10n - .currently_downloading(downloadManager.totalDownloads), + .currently_downloading(downloadManager.$downloadCount), maxLines: 1, style: Theme.of(context).textTheme.headlineMedium, ), @@ -42,7 +41,7 @@ class UserDownloads extends HookConsumerWidget { backgroundColor: Colors.red[50], foregroundColor: Colors.red[400], ), - onPressed: downloadManager.totalDownloads == 0 + onPressed: downloadManager.$downloadCount == 0 ? null : downloadManager.cancelAll, child: Text(context.l10n.cancel_all), @@ -53,24 +52,16 @@ class UserDownloads extends HookConsumerWidget { Expanded( child: SafeArea( child: ListView.builder( - itemCount: downloadManager.totalDownloads, + itemCount: downloadManager.$downloadCount, itemBuilder: (context, index) { - final track = downloadManager.items.elementAt(index); + final track = downloadManager.$history.elementAt(index); return HookBuilder(builder: (context) { - final task = useStream( - downloadManager.activeDownloadProgress.stream - .where((element) => element.task.taskId == track.id), + final taskStatus = useListenable( + useMemoized( + () => downloadManager.getStatusNotifier(track), + [track], + ), ); - final failedTaskStream = useStream( - downloadManager.failedDownloads.stream - .where((element) => element.taskId == track.id), - ); - final taskItSelf = useFuture( - FileDownloader().database.recordForId(track.id!), - ); - - final hasFailed = failedTaskStream.hasData || - taskItSelf.data?.status == TaskStatus.failed; return ListTile( title: Text(track.name ?? ''), @@ -88,19 +79,83 @@ class UserDownloads extends HookConsumerWidget { ), ), ), - horizontalTitleGap: 10, - trailing: downloadManager.activeItem?.id == track.id && - !hasFailed - ? CircularProgressIndicator( - value: task.data?.progress ?? 0, - ) - : hasFailed - ? Icon(SpotubeIcons.error, color: Colors.red[400]) - : IconButton( + trailing: taskStatus == null + ? null + : switch (taskStatus.value) { + DownloadStatus.downloading => + HookBuilder(builder: (context) { + final taskProgress = useListenable(useMemoized( + () => downloadManager + .getProgressNotifier(track), + [track], + )); + return SizedBox( + width: 140, + child: Row( + children: [ + CircularProgressIndicator( + value: taskProgress?.value ?? 0, + ), + const SizedBox(width: 10), + IconButton( + icon: const Icon(SpotubeIcons.pause), + onPressed: () { + downloadManager.pause(track); + }), + const SizedBox(width: 10), + IconButton( + icon: const Icon(SpotubeIcons.close), + onPressed: () { + downloadManager.cancel(track); + }), + ], + ), + ); + }), + DownloadStatus.paused => Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(SpotubeIcons.play), + onPressed: () { + downloadManager.resume(track); + }), + const SizedBox(width: 10), + IconButton( + icon: const Icon(SpotubeIcons.close), + onPressed: () { + downloadManager.cancel(track); + }) + ], + ), + DownloadStatus.failed || + DownloadStatus.canceled => + SizedBox( + width: 100, + child: Row( + children: [ + Icon( + SpotubeIcons.error, + color: Colors.red[400], + ), + const SizedBox(width: 10), + IconButton( + icon: const Icon(SpotubeIcons.refresh), + onPressed: () { + downloadManager.retry(track); + }, + ), + ], + ), + ), + DownloadStatus.completed => + Icon(SpotubeIcons.done, color: Colors.green[400]), + DownloadStatus.queued => IconButton( icon: const Icon(SpotubeIcons.close), onPressed: () { - downloadManager.cancel(track); + downloadManager.removeFromQueue(track); }), + }, subtitle: TypeConversionUtils.artists_X_ClickableArtists( track.artists ?? [], mainAxisAlignment: WrapAlignment.start, diff --git a/lib/components/player/player_actions.dart b/lib/components/player/player_actions.dart index 78d90ff1..78fb53b7 100644 --- a/lib/components/player/player_actions.dart +++ b/lib/components/player/player_actions.dart @@ -13,6 +13,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/duration.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/models/logger.dart'; +import 'package:spotube/models/spotube_track.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; @@ -39,8 +40,14 @@ class PlayerActions extends HookConsumerWidget { final isLocalTrack = playlist.activeTrack is LocalTrack; ref.watch(downloadManagerProvider); final downloader = ref.watch(downloadManagerProvider.notifier); - final isInQueue = downloader.activeItem != null && - downloader.activeItem!.id == playlist.activeTrack?.id; + final isInQueue = useMemoized(() { + if (playlist.activeTrack == null) return false; + return downloader.isActive(playlist.activeTrack!); + }, [ + playlist.activeTrack, + downloader, + ]); + final localTracks = [] /* ref.watch(localTracksProvider).value */; final auth = ref.watch(AuthenticationNotifier.provider); final sleepTimer = ref.watch(SleepTimerNotifier.provider); @@ -139,7 +146,7 @@ class PlayerActions extends HookConsumerWidget { isDownloaded ? SpotubeIcons.done : SpotubeIcons.download, ), onPressed: playlist.activeTrack != null - ? () => downloader.enqueue(playlist.activeTrack!) + ? () => downloader.addToQueue(playlist.activeTrack!) : null, ), if (playlist.activeTrack != null && !isLocalTrack && auth != null) diff --git a/lib/components/root/sidebar.dart b/lib/components/root/sidebar.dart index 64525823..61f10509 100644 --- a/lib/components/root/sidebar.dart +++ b/lib/components/root/sidebar.dart @@ -52,7 +52,7 @@ class Sidebar extends HookConsumerWidget { final mediaQuery = MediaQuery.of(context); final downloadCount = ref.watch( - downloadManagerProvider.select((s) => s.length), + downloadManagerProvider.select((s) => s.$downloadCount), ); final layoutMode = diff --git a/lib/components/root/spotube_navigation_bar.dart b/lib/components/root/spotube_navigation_bar.dart index 111e7dd5..fca4393b 100644 --- a/lib/components/root/spotube_navigation_bar.dart +++ b/lib/components/root/spotube_navigation_bar.dart @@ -27,7 +27,7 @@ class SpotubeNavigationBar extends HookConsumerWidget { Widget build(BuildContext context, ref) { final theme = Theme.of(context); final downloadCount = ref.watch( - downloadManagerProvider.select((s) => s.length), + downloadManagerProvider.select((s) => s.$downloadCount), ); final mediaQuery = MediaQuery.of(context); final layoutMode = diff --git a/lib/components/shared/track_table/track_options.dart b/lib/components/shared/track_table/track_options.dart index 13c1e5d5..49eac822 100644 --- a/lib/components/shared/track_table/track_options.dart +++ b/lib/components/shared/track_table/track_options.dart @@ -14,6 +14,7 @@ import 'package:spotube/components/shared/heart_button.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/local_track.dart'; +import 'package:spotube/models/spotube_track.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/download_manager_provider.dart'; @@ -99,6 +100,20 @@ class TrackOptions extends HookConsumerWidget { playlistId ?? "", ); + final isInQueue = useMemoized(() { + if (playlist.activeTrack == null) return false; + return downloadManager.isActive(playlist.activeTrack!); + }, [ + playlist.activeTrack, + downloadManager, + ]); + + final progressNotifier = useMemoized(() { + final spotubeTrack = downloadManager.mapToSpotubeTrack(track); + if (spotubeTrack == null) return null; + return downloadManager.getProgressNotifier(spotubeTrack); + }); + return ListTileTheme( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), @@ -175,7 +190,7 @@ class TrackOptions extends HookConsumerWidget { ); break; case TrackOptionValue.download: - await downloadManager.enqueue(track); + await downloadManager.addToQueue(track); break; } }, @@ -268,9 +283,14 @@ class TrackOptions extends HookConsumerWidget { ), PopSheetEntry( value: TrackOptionValue.download, - enabled: downloadManager.activeItem?.id != track.id!, - leading: downloadManager.activeItem?.id == track.id! - ? const CircularProgressIndicator() + enabled: isInQueue, + leading: isInQueue + ? HookBuilder(builder: (context) { + final progress = useListenable(progressNotifier!); + return CircularProgressIndicator( + value: progress.value, + ); + }) : const Icon(SpotubeIcons.download), title: Text(context.l10n.download_track), ), diff --git a/lib/components/shared/track_table/tracks_table_view.dart b/lib/components/shared/track_table/tracks_table_view.dart index 6172f501..d412dd36 100644 --- a/lib/components/shared/track_table/tracks_table_view.dart +++ b/lib/components/shared/track_table/tracks_table_view.dart @@ -196,7 +196,7 @@ class TracksTableView extends HookConsumerWidget { ); if (confirmed != true) return; await downloader - .enqueueAll(selectedTracks.toList()); + .batchAddToQueue(selectedTracks.toList()); if (context.mounted) { selected.value = []; showCheck.value = false; diff --git a/lib/pages/library/library.dart b/lib/pages/library/library.dart index 15434f19..fe6f7b93 100644 --- a/lib/pages/library/library.dart +++ b/lib/pages/library/library.dart @@ -16,7 +16,7 @@ class LibraryPage extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final downloadingCount = - ref.watch(downloadManagerProvider.select((s) => s.length)); + ref.watch(downloadManagerProvider.select((s) => s.$downloadCount)); return DefaultTabController( length: 5, diff --git a/lib/pages/root/root_app.dart b/lib/pages/root/root_app.dart index a9ef43da..e144e3c6 100644 --- a/lib/pages/root/root_app.dart +++ b/lib/pages/root/root_app.dart @@ -33,7 +33,7 @@ class RootApp extends HookConsumerWidget { final index = useState(0); final isMounted = useIsMounted(); final showingDialogCompleter = useRef(Completer()..complete()); - final downloader = ref.watch(downloadManagerProvider.notifier); + final downloader = ref.watch(downloadManagerProvider); useEffect(() { WidgetsBinding.instance.addPostFrameCallback((_) async { diff --git a/lib/pages/settings/settings.dart b/lib/pages/settings/settings.dart index 10e5139b..033d2946 100644 --- a/lib/pages/settings/settings.dart +++ b/lib/pages/settings/settings.dart @@ -23,7 +23,6 @@ import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/l10n/l10n.dart'; import 'package:spotube/models/matched_track.dart'; -import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/user_preferences_provider.dart'; import 'package:spotube/provider/piped_instances_provider.dart'; @@ -36,8 +35,6 @@ class SettingsPage extends HookConsumerWidget { Widget build(BuildContext context, ref) { final UserPreferences preferences = ref.watch(userPreferencesProvider); final auth = ref.watch(AuthenticationNotifier.provider); - final isDownloading = - ref.watch(downloadManagerProvider.select((s) => s.isNotEmpty)); final theme = Theme.of(context); final mediaQuery = MediaQuery.of(context); @@ -449,21 +446,15 @@ class SettingsPage extends HookConsumerWidget { SectionCardWithHeading( heading: context.l10n.downloads, children: [ - Tooltip( - message: isDownloading - ? context.l10n.wait_for_download_to_finish - : "", - child: ListTile( - leading: const Icon(SpotubeIcons.download), - title: Text(context.l10n.download_location), - subtitle: Text(preferences.downloadLocation), - trailing: FilledButton( - onPressed: - isDownloading ? null : pickDownloadLocation, - child: const Icon(SpotubeIcons.folder), - ), - onTap: isDownloading ? null : pickDownloadLocation, + ListTile( + leading: const Icon(SpotubeIcons.download), + title: Text(context.l10n.download_location), + subtitle: Text(preferences.downloadLocation), + trailing: FilledButton( + onPressed: pickDownloadLocation, + child: const Icon(SpotubeIcons.folder), ), + onTap: pickDownloadLocation, ), SwitchListTile( secondary: const Icon(SpotubeIcons.lyrics), diff --git a/lib/provider/download_manager_provider.dart b/lib/provider/download_manager_provider.dart index 0de027d4..6a3dc4e7 100644 --- a/lib/provider/download_manager_provider.dart +++ b/lib/provider/download_manager_provider.dart @@ -1,196 +1,249 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; -import 'package:background_downloader/background_downloader.dart'; +import 'package:catcher/catcher.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:http/http.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:metadata_god/metadata_god.dart'; import 'package:path/path.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/collections/routes.dart'; -import 'package:spotube/components/shared/dialogs/replace_downloaded_dialog.dart'; import 'package:spotube/models/spotube_track.dart'; - import 'package:spotube/provider/user_preferences_provider.dart'; import 'package:spotube/provider/youtube_provider.dart'; +import 'package:spotube/services/download_manager/download_manager.dart'; import 'package:spotube/services/youtube/youtube.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; -class DownloadManagerProvider extends StateNotifier> { - final Ref ref; +class DownloadManagerProvider extends ChangeNotifier { + DownloadManagerProvider({required this.ref}) + : $history = {}, + backHistory = {}, + dl = DownloadManager() { + dl.statusStream.listen((event) async { + final (:request, :status) = event; - final StreamController activeDownloadProgress; - final StreamController failedDownloads; - Track? _activeItem; + final track = $history.firstWhereOrNull( + (element) => element.ytUri == request.url, + ); + if (track == null) return; - FutureOr Function(Track)? onFileExists; + final savePath = getTrackFileUrl(track); + final oldFile = File("$savePath.old"); - DownloadManagerProvider(this.ref) - : activeDownloadProgress = StreamController.broadcast(), - failedDownloads = StreamController.broadcast(), - super([]) { - if (kIsWeb) return; + if ((status == DownloadStatus.failed || + status == DownloadStatus.canceled) && + await oldFile.exists()) { + await oldFile.rename(savePath); + } + if (status != DownloadStatus.completed) return; - FileDownloader().registerCallbacks( - group: FileDownloader.defaultGroup, - taskNotificationTapCallback: (task, notificationType) { - router.go("/library"); - }, - taskStatusCallback: (update) async { - if (update.status == TaskStatus.running) { - _activeItem = - state.firstWhereOrNull((track) => track.id == update.task.taskId); - state = state.toList(); - } + var file = File(request.path); - if (update.status == TaskStatus.failed || - update.status == TaskStatus.notFound) { - failedDownloads.add(update.task); - } + file.copySync(savePath); + file.deleteSync(); - if (update.status == TaskStatus.complete) { - final track = - state.firstWhere((element) => element.id == update.task.taskId); + file = File(savePath); - // resetting the replace downloaded file state on queue completion - if (state.last == track) { - ref.read(replaceDownloadedFileState.notifier).state = null; - } + if (await oldFile.exists()) { + await oldFile.delete(); + } - state = state - .where((element) => element.id != update.task.taskId) - .toList(); - - final imageUri = TypeConversionUtils.image_X_UrlString( - track.album?.images ?? [], - placeholder: ImagePlaceholder.online, - ); - final response = await get(Uri.parse(imageUri)); - - final tempFile = File(await update.task.filePath()); - - final file = tempFile.copySync(_getPathForTrack(track)); - - await tempFile.delete(); - - await MetadataGod.writeMetadata( - file: file.path, - metadata: Metadata( - title: track.name, - artist: track.artists?.map((a) => a.name).join(", "), - album: track.album?.name, - albumArtist: track.artists?.map((a) => a.name).join(", "), - year: track.album?.releaseDate != null - ? int.tryParse(track.album!.releaseDate!) - : null, - trackNumber: track.trackNumber, - discNumber: track.discNumber, - durationMs: track.durationMs?.toDouble(), - fileSize: file.lengthSync(), - trackTotal: track.album?.tracks?.length, - picture: response.headers['content-type'] != null - ? Picture( - data: response.bodyBytes, - mimeType: response.headers['content-type']!, - ) - : null, - ), - ); - } - }, - taskProgressCallback: (update) { - activeDownloadProgress.add(update); - }, - ); - FileDownloader().trackTasks(markDownloadedComplete: true); - } - - UserPreferences get preferences => ref.read(userPreferencesProvider); - YoutubeEndpoints get youtube => ref.read(youtubeProvider); - - int get totalDownloads => state.length; - List get items => state; - Track? get activeItem => _activeItem; - - String _getPathForTrack(Track track) => join( - preferences.downloadLocation, - "${track.name} - ${track.artists?.map((a) => a.name).join(", ")}.m4a", + final imageBytes = await downloadImage( + TypeConversionUtils.image_X_UrlString(track.album?.images, + placeholder: ImagePlaceholder.albumArt, index: 1), ); - Future _ensureSpotubeTrack(Track track) async { - if (state.any((element) => element.id == track.id)) { - final task = await FileDownloader().taskForId(track.id!); - if (task != null) { - return task; - } - // this makes sure we already have the fetched track - track = state.firstWhere((element) => element.id == track.id); - state.removeWhere((element) => element.id == track.id); - } - final spotubeTrack = track is SpotubeTrack - ? track - : await SpotubeTrack.fetchFromTrack( - track, - youtube, - ); - state = [...state, spotubeTrack]; - final task = DownloadTask( - url: spotubeTrack.ytUri, - baseDirectory: BaseDirectory.applicationSupport, - taskId: spotubeTrack.id!, - updates: Updates.statusAndProgress, - ); - return task; + final metadata = Metadata( + title: track.name, + artist: track.artists?.map((a) => a.name).join(", "), + album: track.album?.name, + albumArtist: track.artists?.map((a) => a.name).join(", "), + year: track.album?.releaseDate != null + ? int.tryParse(track.album!.releaseDate!) + : null, + trackNumber: track.trackNumber, + discNumber: track.discNumber, + durationMs: track.durationMs?.toDouble(), + fileSize: file.lengthSync(), + trackTotal: track.album?.tracks?.length, + picture: imageBytes != null + ? Picture( + data: imageBytes, + // Spotify images are always JPEGs + mimeType: 'image/jpeg', + ) + : null, + ); + + await MetadataGod.writeMetadata( + file: file.path, + metadata: metadata, + ); + }); } - Future enqueue(Track track) async { - final replaceFileGlobal = ref.read(replaceDownloadedFileState); - final file = File(_getPathForTrack(track)); - if (file.existsSync() && - (replaceFileGlobal ?? await onFileExists?.call(track)) != true) { - if (state.isEmpty) { - ref.read(replaceDownloadedFileState.notifier).state = null; + Future Function(Track track) onFileExists = (Track track) async => true; + + final Ref ref; + + YoutubeEndpoints get yt => ref.read(youtubeProvider); + String get downloadDirectory => + ref.read(userPreferencesProvider.select((s) => s.downloadLocation)); + + int get $downloadCount => dl + .getAllDownloads() + .where( + (download) => + download.status.value == DownloadStatus.downloading || + download.status.value == DownloadStatus.paused || + download.status.value == DownloadStatus.queued, + ) + .length; + + final Set $history; + // these are the tracks which metadata hasn't been fetched yet + final Set backHistory; + final DownloadManager dl; + + /// Spotify Images are always JPEGs + Future downloadImage( + String imageUrl, + ) async { + try { + final fileStream = DefaultCacheManager().getImageFile(imageUrl); + + final bytes = List.empty(growable: true); + + await for (final data in fileStream) { + if (data is FileInfo) { + bytes.addAll(data.file.readAsBytesSync()); + break; + } } + + return Uint8List.fromList(bytes); + } catch (e, stackTrace) { + Catcher.reportCheckedError(e, stackTrace); return null; } - - final task = await _ensureSpotubeTrack(track); - - await FileDownloader().enqueue(task); - return task; } - Future> enqueueAll(List tracks) async { - final tasks = await Future.wait(tracks.mapIndexed((i, e) { - if (i != 0) { - /// One second delay between each download to avoid - /// clogging the Piped server with too many requests - return Future.delayed(const Duration(seconds: 1), () => enqueue(e)); - } - return enqueue(e); - })); + String getTrackFileUrl(Track track) { + final name = + "${track.name} - ${TypeConversionUtils.artists_X_String(track.artists ?? [])}.m4a"; + return join(downloadDirectory, name); + } - if (tasks.isEmpty) { - ref.read(replaceDownloadedFileState.notifier).state = null; + bool isActive(Track track) { + if (backHistory.contains(track)) return true; + + final spotubeTrack = mapToSpotubeTrack(track); + + if (spotubeTrack == null) return false; + + return dl + .getAllDownloads() + .where( + (download) => + download.status.value == DownloadStatus.downloading || + download.status.value == DownloadStatus.paused || + download.status.value == DownloadStatus.queued, + ) + .map((e) => e.request.url) + .contains(spotubeTrack.ytUri); + } + + /// For singular downloads + Future addToQueue(Track track) async { + final savePath = getTrackFileUrl(track); + + final oldFile = File(savePath); + if (await oldFile.exists() && !await onFileExists(track)) { + return; } - return tasks.whereType().toList(); + if (await oldFile.exists()) { + await oldFile.rename("$savePath.old"); + } + + if (track is SpotubeTrack) { + final downloadTask = await dl.addDownload(track.ytUri, savePath); + if (downloadTask != null) { + $history.add(track); + } + } else { + backHistory.add(track); + final spotubeTrack = + await SpotubeTrack.fetchFromTrack(track, yt).then((d) { + backHistory.remove(track); + return d; + }); + final downloadTask = await dl.addDownload(spotubeTrack.ytUri, savePath); + if (downloadTask != null) { + $history.add(spotubeTrack); + } + } + + notifyListeners(); } - Future cancel(Track track) async { - await FileDownloader().cancelTaskWithId(track.id!); - state = state.where((element) => element.id != track.id).toList(); + Future batchAddToQueue(List tracks) async { + backHistory.addAll( + tracks.where((element) => element is! SpotubeTrack), + ); + for (final track in tracks) { + await addToQueue(track); + await Future.delayed(const Duration(seconds: 2)); + } } - Future cancelAll() async { - (await FileDownloader().reset()); - state = []; + Future removeFromQueue(SpotubeTrack track) async { + await dl.removeDownload(track.ytUri); + $history.remove(track); + } + + Future pause(SpotubeTrack track) { + return dl.pauseDownload(track.ytUri); + } + + Future resume(SpotubeTrack track) { + return dl.resumeDownload(track.ytUri); + } + + Future retry(SpotubeTrack track) { + return addToQueue(track); + } + + void cancel(SpotubeTrack track) { + dl.cancelDownload(track.ytUri); + } + + void cancelAll() { + for (final download in dl.getAllDownloads()) { + dl.cancelDownload(download.request.url); + } + } + + SpotubeTrack? mapToSpotubeTrack(Track track) { + if (track is SpotubeTrack) { + return track; + } else { + return $history.firstWhereOrNull((element) => element.id == track.id); + } + } + + ValueNotifier? getStatusNotifier(SpotubeTrack track) { + return dl.getDownload(track.ytUri)?.status; + } + + ValueNotifier? getProgressNotifier(SpotubeTrack track) { + return dl.getDownload(track.ytUri)?.progress; } } -final downloadManagerProvider = - StateNotifierProvider>( - DownloadManagerProvider.new, +final downloadManagerProvider = ChangeNotifierProvider( + (ref) => DownloadManagerProvider(ref: ref), ); diff --git a/lib/services/download_manager/download_manager.dart b/lib/services/download_manager/download_manager.dart new file mode 100644 index 00000000..ddfce8a0 --- /dev/null +++ b/lib/services/download_manager/download_manager.dart @@ -0,0 +1,413 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:io'; +import 'package:collection/collection.dart'; + +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:spotube/services/download_manager/download_request.dart'; +import 'package:spotube/services/download_manager/download_status.dart'; +import 'package:spotube/services/download_manager/download_task.dart'; + +export './download_request.dart'; +export './download_status.dart'; +export './download_task.dart'; + +typedef DownloadStatusEvent = ({ + DownloadStatus status, + DownloadRequest request +}); + +class DownloadManager { + final Map _cache = {}; + final Queue _queue = Queue(); + var dio = Dio(); + static const partialExtension = ".partial"; + static const tempExtension = ".temp"; + + // var tasks = StreamController(); + + final _statusStreamController = + StreamController.broadcast(); + Stream get statusStream => + _statusStreamController.stream; + + int maxConcurrentTasks = 2; + int runningTasks = 0; + + static final DownloadManager _dm = DownloadManager._internal(); + + DownloadManager._internal(); + + factory DownloadManager({int? maxConcurrentTasks}) { + if (maxConcurrentTasks != null) { + _dm.maxConcurrentTasks = maxConcurrentTasks; + } + return _dm; + } + + void Function(int, int) createCallback(url, int partialFileLength) => + (int received, int total) { + getDownload(url)?.progress.value = + (received + partialFileLength) / (total + partialFileLength); + + if (total == -1) {} + }; + + Future download(String url, String savePath, cancelToken, + {forceDownload = false}) async { + late String partialFilePath; + late File partialFile; + try { + var task = getDownload(url); + + if (task == null || task.status.value == DownloadStatus.canceled) { + return; + } + setStatus(task, DownloadStatus.downloading); + + debugPrint(url); + var file = File(savePath.toString()); + partialFilePath = savePath + partialExtension; + partialFile = File(partialFilePath); + + var fileExist = await file.exists(); + var partialFileExist = await partialFile.exists(); + + if (fileExist) { + debugPrint("File Exists"); + setStatus(task, DownloadStatus.completed); + } else if (partialFileExist) { + debugPrint("Partial File Exists"); + + var partialFileLength = await partialFile.length(); + + var response = await dio.download( + url, + partialFilePath + tempExtension, + onReceiveProgress: createCallback(url, partialFileLength), + options: Options( + headers: { + HttpHeaders.rangeHeader: 'bytes=$partialFileLength-', + HttpHeaders.connectionHeader: "close", + }, + ), + cancelToken: cancelToken, + deleteOnError: true, + ); + + if (response.statusCode == HttpStatus.partialContent) { + var ioSink = partialFile.openWrite(mode: FileMode.writeOnlyAppend); + var _f = File(partialFilePath + tempExtension); + await ioSink.addStream(_f.openRead()); + await _f.delete(); + await ioSink.close(); + await partialFile.rename(savePath); + + setStatus(task, DownloadStatus.completed); + } + } else { + var response = await dio.download( + url, + partialFilePath, + onReceiveProgress: createCallback(url, 0), + options: Options(headers: { + HttpHeaders.connectionHeader: "close", + }), + cancelToken: cancelToken, + deleteOnError: false, + ); + + if (response.statusCode == HttpStatus.ok) { + await partialFile.rename(savePath); + setStatus(task, DownloadStatus.completed); + } + } + } catch (e) { + var task = getDownload(url)!; + if (task.status.value != DownloadStatus.canceled && + task.status.value != DownloadStatus.paused) { + setStatus(task, DownloadStatus.failed); + runningTasks--; + + if (_queue.isNotEmpty) { + _startExecution(); + } + rethrow; + } else if (task.status.value == DownloadStatus.paused) { + final ioSink = partialFile.openWrite(mode: FileMode.writeOnlyAppend); + final f = File(partialFilePath + tempExtension); + if (await f.exists()) { + await ioSink.addStream(f.openRead()); + } + await ioSink.close(); + } + } + + runningTasks--; + + if (_queue.isNotEmpty) { + _startExecution(); + } + } + + void disposeNotifiers(DownloadTask task) { + // task.status.dispose(); + // task.progress.dispose(); + } + + void setStatus(DownloadTask? task, DownloadStatus status) { + if (task != null) { + task.status.value = status; + + // tasks.add(task); + if (status.isCompleted) { + disposeNotifiers(task); + } + + _statusStreamController.add((status: status, request: task.request)); + } + } + + Future addDownload(String url, String savedDir) async { + if (url.isNotEmpty) { + if (savedDir.isEmpty) { + savedDir = "."; + } + + var isDirectory = await Directory(savedDir).exists(); + var downloadFilename = isDirectory + ? savedDir + Platform.pathSeparator + getFileNameFromUrl(url) + : savedDir; + + return _addDownloadRequest(DownloadRequest(url, downloadFilename)); + } + + return null; + } + + Future _addDownloadRequest( + DownloadRequest downloadRequest, + ) async { + if (_cache[downloadRequest.url] != null) { + if (!_cache[downloadRequest.url]!.status.value.isCompleted && + _cache[downloadRequest.url]!.request == downloadRequest) { + // Do nothing + return _cache[downloadRequest.url]!; + } else { + _queue.remove(_cache[downloadRequest.url]); + } + } + + _queue.add(DownloadRequest(downloadRequest.url, downloadRequest.path)); + var task = DownloadTask(_queue.last); + + _cache[downloadRequest.url] = task; + + _startExecution(); + + return task; + } + + Future pauseDownload(String url) async { + debugPrint("Pause Download"); + var task = getDownload(url)!; + setStatus(task, DownloadStatus.paused); + task.request.cancelToken.cancel(); + + _queue.remove(task.request); + } + + Future cancelDownload(String url) async { + debugPrint("Cancel Download"); + var task = getDownload(url)!; + setStatus(task, DownloadStatus.canceled); + _queue.remove(task.request); + task.request.cancelToken.cancel(); + } + + Future resumeDownload(String url) async { + debugPrint("Resume Download"); + var task = getDownload(url)!; + setStatus(task, DownloadStatus.downloading); + task.request.cancelToken = CancelToken(); + _queue.add(task.request); + + _startExecution(); + } + + Future removeDownload(String url) async { + cancelDownload(url); + _cache.remove(url); + } + + // Do not immediately call getDownload After addDownload, rather use the returned DownloadTask from addDownload + DownloadTask? getDownload(String url) { + return _cache[url]; + } + + Future whenDownloadComplete(String url, + {Duration timeout = const Duration(hours: 2)}) async { + DownloadTask? task = getDownload(url); + + if (task != null) { + return task.whenDownloadComplete(timeout: timeout); + } else { + return Future.error("Not found"); + } + } + + List getAllDownloads() { + return _cache.values.toList(); + } + + // Batch Download Mechanism + Future addBatchDownloads(List urls, String savedDir) async { + urls.forEach((url) { + addDownload(url, savedDir); + }); + } + + List getBatchDownloads(List urls) { + return urls.map((e) => _cache[e]).toList(); + } + + Future pauseBatchDownloads(List urls) async { + urls.forEach((element) { + pauseDownload(element); + }); + } + + Future cancelBatchDownloads(List urls) async { + urls.forEach((element) { + cancelDownload(element); + }); + } + + Future resumeBatchDownloads(List urls) async { + urls.forEach((element) { + resumeDownload(element); + }); + } + + ValueNotifier getBatchDownloadProgress(List urls) { + ValueNotifier progress = ValueNotifier(0); + var total = urls.length; + + if (total == 0) { + return progress; + } + + if (total == 1) { + return getDownload(urls.first)?.progress ?? progress; + } + + var progressMap = Map(); + + urls.forEach((url) { + DownloadTask? task = getDownload(url); + + if (task != null) { + progressMap[url] = 0.0; + + if (task.status.value.isCompleted) { + progressMap[url] = 1.0; + progress.value = progressMap.values.sum / total; + } + + var progressListener; + progressListener = () { + progressMap[url] = task.progress.value; + progress.value = progressMap.values.sum / total; + }; + + task.progress.addListener(progressListener); + + var listener; + listener = () { + if (task.status.value.isCompleted) { + progressMap[url] = 1.0; + progress.value = progressMap.values.sum / total; + task.status.removeListener(listener); + task.progress.removeListener(progressListener); + } + }; + + task.status.addListener(listener); + } else { + total--; + } + }); + + return progress; + } + + Future?> whenBatchDownloadsComplete(List urls, + {Duration timeout = const Duration(hours: 2)}) async { + var completer = Completer?>(); + + var completed = 0; + var total = urls.length; + + urls.forEach((url) { + DownloadTask? task = getDownload(url); + + if (task != null) { + if (task.status.value.isCompleted) { + completed++; + + if (completed == total) { + completer.complete(getBatchDownloads(urls)); + } + } + + var listener; + listener = () { + if (task.status.value.isCompleted) { + completed++; + + if (completed == total) { + completer.complete(getBatchDownloads(urls)); + task.status.removeListener(listener); + } + } + }; + + task.status.addListener(listener); + } else { + total--; + + if (total == 0) { + completer.complete(null); + } + } + }); + + return completer.future.timeout(timeout); + } + + void _startExecution() async { + if (runningTasks == maxConcurrentTasks || _queue.isEmpty) { + return; + } + + while (_queue.isNotEmpty && runningTasks < maxConcurrentTasks) { + runningTasks++; + debugPrint('Concurrent workers: $runningTasks'); + var currentRequest = _queue.removeFirst(); + + await download( + currentRequest.url, + currentRequest.path, + currentRequest.cancelToken, + ); + + await Future.delayed(const Duration(milliseconds: 500), null); + } + } + + /// This function is used for get file name with extension from url + String getFileNameFromUrl(String url) { + return url.split('/').last; + } +} diff --git a/lib/services/download_manager/download_request.dart b/lib/services/download_manager/download_request.dart new file mode 100644 index 00000000..80c4af37 --- /dev/null +++ b/lib/services/download_manager/download_request.dart @@ -0,0 +1,24 @@ +import 'package:dio/dio.dart'; + +class DownloadRequest { + final String url; + final String path; + var cancelToken = CancelToken(); + var forceDownload = false; + + DownloadRequest( + this.url, + this.path, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is DownloadRequest && + runtimeType == other.runtimeType && + url == other.url && + path == other.path; + + @override + int get hashCode => url.hashCode ^ path.hashCode; +} diff --git a/lib/services/download_manager/download_status.dart b/lib/services/download_manager/download_status.dart new file mode 100644 index 00000000..b97080fa --- /dev/null +++ b/lib/services/download_manager/download_status.dart @@ -0,0 +1,26 @@ +enum DownloadStatus { + queued, + downloading, + completed, + failed, + paused, + canceled; + + bool get isCompleted { + switch (this) { + case DownloadStatus.queued: + return false; + case DownloadStatus.downloading: + return false; + case DownloadStatus.paused: + return false; + case DownloadStatus.completed: + return true; + case DownloadStatus.failed: + return true; + + case DownloadStatus.canceled: + return true; + } + } +} diff --git a/lib/services/download_manager/download_task.dart b/lib/services/download_manager/download_task.dart new file mode 100644 index 00000000..5d57a655 --- /dev/null +++ b/lib/services/download_manager/download_task.dart @@ -0,0 +1,36 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:spotube/services/download_manager/download_request.dart'; +import 'package:spotube/services/download_manager/download_status.dart'; + +class DownloadTask { + final DownloadRequest request; + ValueNotifier status = ValueNotifier(DownloadStatus.queued); + ValueNotifier progress = ValueNotifier(0); + + DownloadTask( + this.request, + ); + + Future whenDownloadComplete( + {Duration timeout = const Duration(hours: 2)}) async { + var completer = Completer(); + + if (status.value.isCompleted) { + completer.complete(status.value); + } + + var listener; + listener = () { + if (status.value.isCompleted) { + completer.complete(status.value); + status.removeListener(listener); + } + }; + + status.addListener(listener); + + return completer.future.timeout(timeout); + } +} diff --git a/pubspec.lock b/pubspec.lock index a83e4dad..6858e8b0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -145,14 +145,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" - background_downloader: - dependency: "direct main" - description: - name: background_downloader - sha256: "5e38a1d5d88a5cfea35c44cb376b89427688070518471ee52f6b04d07d85668e" - url: "https://pub.dev" - source: hosted - version: "7.4.0" boolean_selector: dependency: transitive description: @@ -459,13 +451,13 @@ packages: source: hosted version: "1.1.0" dio: - dependency: transitive + dependency: "direct main" description: name: dio - sha256: "347d56c26d63519552ef9a569f2a593dda99a81fdbdff13c584b7197cfe05059" + sha256: ce75a1b40947fea0a0e16ce73337122a86762e38b982e1ccb909daa3b9bc4197 url: "https://pub.dev" source: hosted - version: "5.1.2" + version: "5.3.2" disable_battery_optimization: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 20909d63..9a77cb32 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -95,13 +95,13 @@ dependencies: piped_client: ^0.1.0 device_preview: ^1.1.0 dbus: ^0.7.8 - background_downloader: ^7.4.0 duration: ^3.0.12 disable_battery_optimization: ^1.1.0+1 youtube_explode_dart: ^1.12.4 flutter_displaymode: ^0.6.0 google_fonts: ^4.0.4 supabase: ^1.9.9 + dio: ^5.3.2 dev_dependencies: build_runner: ^2.3.2