From 2444d933c3f4e33f059df6621a29f4f17a23495f Mon Sep 17 00:00:00 2001 From: Komodo <45665554+Komodo5197@users.noreply.github.com> Date: Tue, 19 Dec 2023 02:41:39 -0500 Subject: [PATCH] Most browsing tasks work now. Initial migration code. --- .../AlbumScreen/album_screen_content.dart | 2 +- lib/components/AlbumScreen/item_info.dart | 40 +- .../AlbumScreen/song_list_tile.dart | 42 +- .../download_error_list_tile.dart | 1 + .../DownloadsScreen/album_file_size.dart | 20 +- .../download_missing_images_button.dart | 13 +- .../downloaded_albums_list.dart | 9 +- lib/components/MusicScreen/album_item.dart | 112 ++- .../MusicScreen/artist_item_list_tile.dart | 32 + .../MusicScreen/music_screen_tab_view.dart | 109 +-- lib/l10n/app_en.arb | 21 +- lib/main.dart | 18 +- lib/models/finamp_models.dart | 28 +- lib/models/finamp_models.g.dart | 683 +++++++++++++++++- lib/screens/album_screen.dart | 4 +- lib/screens/artist_screen.dart | 1 - lib/screens/downloads_error_screen.dart | 3 +- lib/screens/downloads_screen.dart | 2 +- lib/services/audio_service_helper.dart | 9 +- lib/services/downloads_helper.dart | 382 +--------- lib/services/finamp_settings_helper.dart | 9 + lib/services/isar_downloads.dart | 369 +++++++--- lib/services/jellyfin_api_helper.dart | 3 + 23 files changed, 1187 insertions(+), 725 deletions(-) diff --git a/lib/components/AlbumScreen/album_screen_content.dart b/lib/components/AlbumScreen/album_screen_content.dart index 0a2748097..85274c039 100644 --- a/lib/components/AlbumScreen/album_screen_content.dart +++ b/lib/components/AlbumScreen/album_screen_content.dart @@ -46,7 +46,7 @@ class _AlbumScreenContentState extends State { // if not in playlist, try splitting up tracks by disc numbers // if first track has a disc number, let's assume the rest has it too if (widget.parent.type != "Playlist" && - widget.children[0].parentIndexNumber != null) { + widget.children.isNotEmpty && widget.children[0].parentIndexNumber != null) { int? lastDiscNumber; for (var child in widget.children) { if (child.parentIndexNumber != null && diff --git a/lib/components/AlbumScreen/item_info.dart b/lib/components/AlbumScreen/item_info.dart index e4ed8c994..507b10223 100644 --- a/lib/components/AlbumScreen/item_info.dart +++ b/lib/components/AlbumScreen/item_info.dart @@ -26,27 +26,35 @@ class ItemInfo extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - if (item.type != "Playlist") _IconAndText( - iconData: Icons.person, - textSpan: TextSpan( - children: ArtistsTextSpans( - item, - null, - context, - false, - ), - ) - ), + if (item.type != "Playlist") + _IconAndText( + iconData: Icons.person, + textSpan: TextSpan( + children: ArtistsTextSpans( + item, + null, + context, + false, + ), + )), _IconAndText( iconData: Icons.music_note, - textSpan: TextSpan(text: AppLocalizations.of(context)!.songCount(itemSongs))), + textSpan: TextSpan( + text: (itemSongs == (item.childCount ?? itemSongs)) + ? AppLocalizations.of(context)!.songCount(itemSongs) + : AppLocalizations.of(context)! + .offlineSongCount(item.childCount!, itemSongs))), _IconAndText( iconData: Icons.timer, - textSpan: TextSpan(text: printDuration(Duration( - microseconds: - item.runTimeTicks == null ? 0 : item.runTimeTicks! ~/ 10)))), + textSpan: TextSpan( + text: printDuration(Duration( + microseconds: item.runTimeTicks == null + ? 0 + : item.runTimeTicks! ~/ 10)))), if (item.type != "Playlist") - _IconAndText(iconData: Icons.event, textSpan: TextSpan(text: item.productionYearString)) + _IconAndText( + iconData: Icons.event, + textSpan: TextSpan(text: item.productionYearString)) ], ); } diff --git a/lib/components/AlbumScreen/song_list_tile.dart b/lib/components/AlbumScreen/song_list_tile.dart index 8344cb902..52fbd7a93 100644 --- a/lib/components/AlbumScreen/song_list_tile.dart +++ b/lib/components/AlbumScreen/song_list_tile.dart @@ -17,6 +17,7 @@ import '../favourite_button.dart'; import '../album_image.dart'; import '../print_duration.dart'; import '../error_snackbar.dart'; +import 'download_dialog.dart'; import 'downloaded_indicator.dart'; enum SongListTileMenuItems { @@ -29,6 +30,7 @@ enum SongListTileMenuItems { goToAlbum, addFavourite, removeFavourite, + download, } class SongListTile extends StatefulWidget { @@ -226,14 +228,9 @@ class _SongListTileState extends State { // its metadata. This function also checks if widget.item.parentId is // null. late final bool canGoToAlbum; - late final BaseItemDto? album; - // TODO clean this long-term album storage back out - does it cross async bound? + final isarDownloads = GetIt.instance(); if (widget.item == null) { canGoToAlbum=false; - } else if (FinampSettingsHelper.finampSettings.isOffline) { - final isarDownloads = GetIt.instance(); - album = isarDownloads.getAlbumDownloadFromSong(widget.item)?.baseItem; - canGoToAlbum = album != null && widget.item.albumId != widget.parentId; } else { canGoToAlbum=widget.item.albumId != widget.parentId; } @@ -338,6 +335,16 @@ class _SongListTileState extends State { title: Text(AppLocalizations.of(context)!.addFavourite), ), ), + // TODO add delete option + PopupMenuItem( + enabled: !isOffline, + value: SongListTileMenuItems.download, + child: ListTile( + leading: const Icon(Icons.file_download), + title: Text(AppLocalizations.of(context)!.downloadItem), + enabled: !isOffline, + ), + ), ], ); @@ -420,7 +427,15 @@ class _SongListTileState extends State { break; case SongListTileMenuItems.goToAlbum: + BaseItemDto? album; if (! FinampSettingsHelper.finampSettings.isOffline) { + // The album should always be downloaded if the song is + album = isarDownloads.getAlbumDownloadFromSong(widget.item)?.baseItem; + if (album==null){ + errorSnackbar("Could not locate downloaded album.", context); + break; + } + } else { // If online, get the album's BaseItemDto from the server. try { album = @@ -442,6 +457,21 @@ class _SongListTileState extends State { break; case null: break; + case SongListTileMenuItems.download: + var item = DownloadStub.fromItem(type: DownloadItemType.song, item: widget.item); + if (FinampSettingsHelper + .finampSettings.downloadLocationsMap.length == + 1) { + await isarDownloads.addDownload(stub: item, downloadLocation: FinampSettingsHelper + .finampSettings.downloadLocationsMap.values.first); + } else { + await showDialog( + context: context, + builder: (context) => DownloadDialog( + item: item, + ), + ); + } } }, child: widget.isSong diff --git a/lib/components/DownloadsErrorScreen/download_error_list_tile.dart b/lib/components/DownloadsErrorScreen/download_error_list_tile.dart index 44343f391..e349fd2ca 100644 --- a/lib/components/DownloadsErrorScreen/download_error_list_tile.dart +++ b/lib/components/DownloadsErrorScreen/download_error_list_tile.dart @@ -5,6 +5,7 @@ import 'package:get_it/get_it.dart'; import '../../models/finamp_models.dart'; import '../../services/downloads_helper.dart'; +import '../../services/isar_downloads.dart'; import '../../services/process_artist.dart'; import '../album_image.dart'; diff --git a/lib/components/DownloadsScreen/album_file_size.dart b/lib/components/DownloadsScreen/album_file_size.dart index 385d27950..88a619b69 100644 --- a/lib/components/DownloadsScreen/album_file_size.dart +++ b/lib/components/DownloadsScreen/album_file_size.dart @@ -4,6 +4,7 @@ import 'package:get_it/get_it.dart'; import '../../models/finamp_models.dart'; import '../../services/downloads_helper.dart'; +import '../../services/isar_downloads.dart'; class AlbumFileSize extends StatelessWidget { const AlbumFileSize({Key? key, required this.downloadedParent}) @@ -13,19 +14,12 @@ class AlbumFileSize extends StatelessWidget { @override Widget build(BuildContext context) { - DownloadsHelper downloadsHelper = GetIt.instance(); - int totalSize = 0; - - /*for (final item in downloadedParent.downloadedChildren.values) { - DownloadedSong? downloadedSong = - downloadsHelper.getDownloadedSong(item.id); - - if (downloadedSong?.mediaSourceInfo.size != null) { - totalSize += downloadedSong!.mediaSourceInfo.size!; + final isarDownloader = GetIt.instance(); + return FutureBuilder(future: isarDownloader.getFileSize(downloadedParent), builder: (context, snapshot) { + if (snapshot.hasData){ + return Text(FileSize.getSize(snapshot.data)); } - }*/ - // TODO implement size retrieval - set recursion limit? How to get image sizes? - - return Text(FileSize.getSize(totalSize)); + return const Text(""); + }); } } diff --git a/lib/components/DownloadsScreen/download_missing_images_button.dart b/lib/components/DownloadsScreen/download_missing_images_button.dart index 62d3b7e3f..2f1a3ea8a 100644 --- a/lib/components/DownloadsScreen/download_missing_images_button.dart +++ b/lib/components/DownloadsScreen/download_missing_images_button.dart @@ -4,6 +4,7 @@ import 'package:get_it/get_it.dart'; import '../../services/downloads_helper.dart'; import '../../services/finamp_settings_helper.dart'; +import '../../services/isar_downloads.dart'; class DownloadMissingImagesButton extends StatefulWidget { const DownloadMissingImagesButton({Key? key}) : super(key: key); @@ -30,17 +31,15 @@ class _DownloadMissingImagesButtonState _enabled = false; }); - final downloadsHelper = GetIt.instance(); - - //final imagesDownloaded = - // await downloadsHelper.downloadMissingImages(); - // TODO find something to do here + final isarDownloads = GetIt.instance(); + // TODO put in widget with updated name and text + await isarDownloads.repairAllDownloads(); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text(AppLocalizations.of(context)! - .downloadedMissingImages(0)), + .repairComplete), )); setState(() { @@ -48,7 +47,7 @@ class _DownloadMissingImagesButtonState }); } : null, - icon: const Icon(Icons.image), + icon: const Icon(Icons.handyman), tooltip: AppLocalizations.of(context)!.downloadMissingImages, ); } diff --git a/lib/components/DownloadsScreen/downloaded_albums_list.dart b/lib/components/DownloadsScreen/downloaded_albums_list.dart index fc9cb1956..351a17cf1 100644 --- a/lib/components/DownloadsScreen/downloaded_albums_list.dart +++ b/lib/components/DownloadsScreen/downloaded_albums_list.dart @@ -91,10 +91,7 @@ class _DownloadedSongsInAlbumListState @override Widget build(BuildContext context) { final List children = - isarDownloads.getAllChildren(widget.parent); - // TODO figure out what to do here. Just filter for songs? - // Handle something like an individual song download? - // Just delete if you can't touch individual songs? + isarDownloads.getVisibleChildren(widget.parent); return Column(children: [ //TODO use a list builder here @@ -102,7 +99,7 @@ class _DownloadedSongsInAlbumListState ListTile( title: Text(song.baseItem?.name ?? "Unknown Name"), leading: AlbumImage(item: song?.baseItem), - trailing: IconButton( + /*trailing: IconButton( icon: const Icon(Icons.delete), onPressed: () => showDialog( context: context, @@ -121,7 +118,7 @@ class _DownloadedSongsInAlbumListState onAborted: () {}, ), ), - ), + ),*/ subtitle: ItemMediaSourceInfo( item: song, ), diff --git a/lib/components/MusicScreen/album_item.dart b/lib/components/MusicScreen/album_item.dart index db339f37c..377c506be 100644 --- a/lib/components/MusicScreen/album_item.dart +++ b/lib/components/MusicScreen/album_item.dart @@ -4,11 +4,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:get_it/get_it.dart'; +import '../../models/finamp_models.dart'; import '../../models/jellyfin_models.dart'; import '../../services/audio_service_helper.dart'; +import '../../services/isar_downloads.dart'; import '../../services/jellyfin_api_helper.dart'; import '../../screens/artist_screen.dart'; import '../../screens/album_screen.dart'; +import '../AlbumScreen/download_dialog.dart'; import '../error_snackbar.dart'; import 'album_item_card.dart'; @@ -19,6 +22,7 @@ enum _AlbumListTileMenuItems { removeFavourite, addToMixList, removeFromMixList, + download, } /// This widget is kind of a shell around AlbumItemCard and AlbumItemListTile. @@ -127,46 +131,61 @@ class _AlbumItemState extends State { ), ), ], - mutableAlbum.userData!.isFavorite - ? PopupMenuItem<_AlbumListTileMenuItems>( - enabled: !isOffline, - value: _AlbumListTileMenuItems.removeFavourite, - child: ListTile( + if (mutableAlbum.userData != null) ...[ + mutableAlbum.userData!.isFavorite + ? PopupMenuItem<_AlbumListTileMenuItems>( enabled: !isOffline, - leading: const Icon(Icons.favorite_border), - title: - Text(AppLocalizations.of(context)!.removeFavourite), - ), - ) - : PopupMenuItem<_AlbumListTileMenuItems>( - enabled: !isOffline, - value: _AlbumListTileMenuItems.addFavourite, - child: ListTile( + value: _AlbumListTileMenuItems.removeFavourite, + child: ListTile( + enabled: !isOffline, + leading: const Icon(Icons.favorite_border), + title: Text( + AppLocalizations.of(context)!.removeFavourite), + ), + ) + : PopupMenuItem<_AlbumListTileMenuItems>( enabled: !isOffline, - leading: const Icon(Icons.favorite), - title: Text(AppLocalizations.of(context)!.addFavourite), - ), - ), - jellyfinApiHelper.selectedMixAlbumIds.contains(mutableAlbum.id) - ? PopupMenuItem<_AlbumListTileMenuItems>( - enabled: !isOffline, - value: _AlbumListTileMenuItems.removeFromMixList, - child: ListTile( + value: _AlbumListTileMenuItems.addFavourite, + child: ListTile( + enabled: !isOffline, + leading: const Icon(Icons.favorite), + title: + Text(AppLocalizations.of(context)!.addFavourite), + ), + ) + ], + if (mutableAlbum.type == "MusicAlbum") ...[ + jellyfinApiHelper.selectedMixAlbumIds.contains(mutableAlbum.id) + ? PopupMenuItem<_AlbumListTileMenuItems>( enabled: !isOffline, - leading: const Icon(Icons.explore_off), - title: - Text(AppLocalizations.of(context)!.removeFromMix), - ), - ) - : PopupMenuItem<_AlbumListTileMenuItems>( - enabled: !isOffline, - value: _AlbumListTileMenuItems.addToMixList, - child: ListTile( + value: _AlbumListTileMenuItems.removeFromMixList, + child: ListTile( + enabled: !isOffline, + leading: const Icon(Icons.explore_off), + title: + Text(AppLocalizations.of(context)!.removeFromMix), + ), + ) + : PopupMenuItem<_AlbumListTileMenuItems>( enabled: !isOffline, - leading: const Icon(Icons.explore), - title: Text(AppLocalizations.of(context)!.addToMix), - ), - ), + value: _AlbumListTileMenuItems.addToMixList, + child: ListTile( + enabled: !isOffline, + leading: const Icon(Icons.explore), + title: Text(AppLocalizations.of(context)!.addToMix), + ), + ) + ], + // TODO add delete option + PopupMenuItem<_AlbumListTileMenuItems>( + enabled: !isOffline, + value: _AlbumListTileMenuItems.download, + child: ListTile( + leading: const Icon(Icons.file_download), + title: Text(AppLocalizations.of(context)!.downloadItem), + enabled: !isOffline, + ), + ), ], ); @@ -174,6 +193,7 @@ class _AlbumItemState extends State { switch (selection) { case _AlbumListTileMenuItems.addToQueue: + // TODO doesn't this break offline? final children = await jellyfinApiHelper.getItems( parentItem: widget.album, sortBy: "ParentIndexNumber,IndexNumber,SortName", @@ -256,6 +276,26 @@ class _AlbumItemState extends State { break; case null: break; + case _AlbumListTileMenuItems.download: + var item = DownloadStub.fromItem( + type: DownloadItemType.collectionDownload, + item: widget.album); + if (FinampSettingsHelper + .finampSettings.downloadLocationsMap.length == + 1) { + final isarDownloads = GetIt.instance(); + await isarDownloads.addDownload( + stub: item, + downloadLocation: FinampSettingsHelper + .finampSettings.downloadLocationsMap.values.first); + } else { + await showDialog( + context: context, + builder: (context) => DownloadDialog( + item: item, + ), + ); + } } }, child: widget.isGrid diff --git a/lib/components/MusicScreen/artist_item_list_tile.dart b/lib/components/MusicScreen/artist_item_list_tile.dart index 6acf943a1..59443de50 100644 --- a/lib/components/MusicScreen/artist_item_list_tile.dart +++ b/lib/components/MusicScreen/artist_item_list_tile.dart @@ -1,10 +1,14 @@ import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:get_it/get_it.dart'; +import '../../models/finamp_models.dart'; import '../../models/jellyfin_models.dart'; import '../../screens/artist_screen.dart'; +import '../../services/isar_downloads.dart'; import '../../services/jellyfin_api_helper.dart'; import '../../services/finamp_settings_helper.dart'; +import '../AlbumScreen/download_dialog.dart'; import '../album_image.dart'; import '../error_snackbar.dart'; @@ -13,8 +17,10 @@ enum ArtistListTileMenuItems { removeFromFavourite, addToMixList, removeFromMixList, + download, } +// TODO why do we want this vs album? Not used offline. class ArtistListTile extends StatefulWidget { const ArtistListTile({ Key? key, @@ -105,6 +111,16 @@ class _ArtistListTileState extends State { enabled: !isOffline, ), ), + // TODO add delete option + PopupMenuItem( + enabled: !isOffline, + value: ArtistListTileMenuItems.download, + child: ListTile( + leading: const Icon(Icons.file_download), + title: Text(AppLocalizations.of(context)!.downloadItem), + enabled: !isOffline, + ), + ), ], ); @@ -161,6 +177,22 @@ class _ArtistListTileState extends State { break; case null: break; + case ArtistListTileMenuItems.download: + var item = DownloadStub.fromItem(type: DownloadItemType.collectionDownload, item: widget.item); + if (FinampSettingsHelper + .finampSettings.downloadLocationsMap.length == + 1) { + final isarDownloads = GetIt.instance(); + await isarDownloads.addDownload(stub: item, downloadLocation: FinampSettingsHelper + .finampSettings.downloadLocationsMap.values.first); + } else { + await showDialog( + context: context, + builder: (context) => DownloadDialog( + item: item, + ), + ); + } } }, child: listTile); diff --git a/lib/components/MusicScreen/music_screen_tab_view.dart b/lib/components/MusicScreen/music_screen_tab_view.dart index eb1816a24..b2400becd 100644 --- a/lib/components/MusicScreen/music_screen_tab_view.dart +++ b/lib/components/MusicScreen/music_screen_tab_view.dart @@ -31,7 +31,6 @@ class MusicScreenTabView extends StatefulWidget { this.sortBy, this.sortOrder, this.view, - this.albumArtist, }) : super(key: key); final TabContentType tabContentType; @@ -41,7 +40,6 @@ class MusicScreenTabView extends StatefulWidget { final SortBy? sortBy; final SortOrder? sortOrder; final BaseItemDto? view; - final String? albumArtist; @override State createState() => _MusicScreenTabViewState(); @@ -257,74 +255,26 @@ class _MusicScreenTabViewState extends State final isarDownloader = GetIt.instance(); - if (widget.tabContentType == TabContentType.artists) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.cloud_off, - size: 64, - color: Colors.white.withOpacity(0.5), - ), - const Padding(padding: EdgeInsets.all(8.0)), - const Text("Offline artists view hasn't been implemented") - ], - ), - ); - } - - offlineSortedItems=[]; - // TODO refactor into a reasonable setup that doesn't miss updates - Future.sync(() async { - List offlineItems=[]; + offlineSortedItems = []; + // TODO refactor into a stream listener or something // TODO implement view/library filtering - is there a robust way to do this? - if (widget.searchTerm == null) { - if (widget.tabContentType == TabContentType.songs) { - // If we're on the songs tab, just get all of the downloaded items - offlineItems = (await isarDownloader.getAllSongs()).map((e) => e.baseItem!).toList(); - } else { - String? albumArtist = widget.albumArtist; - // TODO create more efficent isar-based filter? - offlineItems = (await isarDownloader.getAllCollections()) - .where((element) => - element.baseItem!.type == - _includeItemTypes(widget.tabContentType) && - (albumArtist == null || - element.baseItem!.albumArtist?.toLowerCase() == - albumArtist.toLowerCase())) - .map((e) => e.baseItem!) - .toList(); - } + if (widget.tabContentType == TabContentType.songs) { + // If we're on the songs tab, just get all of the downloaded items + offlineSortedItems = isarDownloader + .getAllSongs(nameFilter: widget.searchTerm) + .map((e) => e.baseItem!) + .toList(); } else { - if (widget.tabContentType == TabContentType.songs) { - offlineItems = (await isarDownloader.getAllSongs()) - .where( - (element) { - return _offlineSearch( - item: element.baseItem!, - searchTerm: widget.searchTerm!, - tabContentType: widget.tabContentType); - }, - ) - .map((e) => e.baseItem!) - .toList(); - } else { - offlineItems = (await isarDownloader.getAllCollections()) - .where( - (element) { - return _offlineSearch( - item: element.baseItem!, - searchTerm: widget.searchTerm!, - tabContentType: widget.tabContentType); - }, - ) - .map((e) => e.baseItem!) - .toList(); - } + offlineSortedItems = isarDownloader + .getAllCollections( + nameFilter: widget.searchTerm, + baseTypeFilter: _includeItemTypes(widget.tabContentType), + relatedTo: widget.parentItem) + .map((e) => e.baseItem!) + .toList(); } - offlineItems!.sort((a, b) { + offlineSortedItems!.sort((a, b) { // if (a.name == null || b.name == null) { // // Returning 0 is the same as both being the same // return 0; @@ -375,8 +325,8 @@ class _MusicScreenTabViewState extends State return a.premiereDate!.compareTo(b.premiereDate!); } case SortBy.random: - // We subtract the result by one so that we can get -1 values - // (see comareTo documentation) + // We subtract the result by one so that we can get -1 values + // (see comareTo documentation) return Random().nextInt(2) - 1; default: throw UnimplementedError( @@ -388,12 +338,8 @@ class _MusicScreenTabViewState extends State if (widget.sortOrder == SortOrder.descending) { // The above sort functions sort in ascending order, so we swap them // when sorting in descending order. - offlineItems = offlineItems!.reversed.toList(); + offlineSortedItems = offlineSortedItems!.reversed.toList(); } - setState(() { - offlineSortedItems = offlineItems; - }); - }); } return Scrollbar( @@ -596,20 +542,3 @@ String _includeItemTypes(TabContentType tabContentType) { throw const FormatException("Unsupported TabContentType"); } } - -// TODO create more efficent isar-based filter? -bool _offlineSearch( - {required BaseItemDto item, - required String searchTerm, - required TabContentType tabContentType}) { - late bool containsName; - - // This horrible thing is for null safety - if (item.name == null) { - containsName = false; - } else { - containsName = item.name!.toLowerCase().contains(searchTerm.toLowerCase()); - } - - return item.type == _includeItemTypes(tabContentType) && containsName; -} \ No newline at end of file diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 1a4f73a80..a9e7e980d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -247,6 +247,17 @@ } } }, + "offlineSongCount": "{count,plural,=1{{count} Song} other{{count} Songs}}, {downloads} Downloaded", + "@offlineSongCount": { + "placeholders": { + "count": { + "type": "int" + }, + "downloads": { + "type": "int" + } + } + }, "editPlaylistNameTooltip": "Edit playlist name", "@editPlaylistNameTooltip": {}, "editPlaylistNameTitle": "Edit Playlist Name", @@ -520,5 +531,13 @@ "description": "Title for message that shows on the views screen when no music libraries could be found." }, "noMusicLibrariesBody": "Finamp could not find any music libraries. Please ensure that your Jellyfin server contains at least one library with the content type set to \"Music\".", - "refresh": "REFRESH" + "refresh": "REFRESH", + "downloadItem": "Download", + "@downloadItem": { + "description": "Option to download item in long-press widget." + }, + "repairComplete": "Downloads Repair complete.", + "@repairComplete": { + "description": "Message displayed after download repair completes." + } } diff --git a/lib/main.dart b/lib/main.dart index ea1db9549..fed74cc13 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -103,21 +103,11 @@ void _setupOfflineListenLogHelper() { Future _setupDownloadsHelper() async { GetIt.instance.registerSingleton(DownloadsHelper()); GetIt.instance.registerSingleton(IsarDownloads()); - final downloadsHelper = GetIt.instance(); + final isarDownloads = GetIt.instance(); - // We awkwardly cache this value since going from 0.6.14 -> 0.6.16 will switch - // hasCompletedBlurhashImageMigration despite doing a fixed migration - final shouldRunBlurhashImageMigrationIdFix = - FinampSettingsHelper.finampSettings.shouldRunBlurhashImageMigrationIdFix; - - if (!FinampSettingsHelper.finampSettings.hasCompletedBlurhashImageMigration) { - await downloadsHelper.migrateBlurhashImages(); - FinampSettingsHelper.setHasCompletedBlurhashImageMigration(true); - } - - if (shouldRunBlurhashImageMigrationIdFix) { - await downloadsHelper.fixBlurhashMigrationIds(); - FinampSettingsHelper.setHasCompletedBlurhashImageMigrationIdFix(true); + if (!FinampSettingsHelper.finampSettings.hasCompletedIsarDownloadsMigration) { + await isarDownloads.migrateFromHive(); + FinampSettingsHelper.setHasCompletedIsarDownloadsMigration(true); } } diff --git a/lib/models/finamp_models.dart b/lib/models/finamp_models.dart index 20a084df1..695855f06 100644 --- a/lib/models/finamp_models.dart +++ b/lib/models/finamp_models.dart @@ -90,6 +90,7 @@ class FinampSettings { this.tabOrder = _tabOrder, this.hasCompletedBlurhashImageMigration = true, this.hasCompletedBlurhashImageMigrationIdFix = true, + this.hasCompletedIsarDownloadsMigration = true, }); @HiveField(0) @@ -182,6 +183,9 @@ class FinampSettings { @HiveField(24, defaultValue: false) bool hasCompletedBlurhashImageMigrationIdFix; + @HiveField(25, defaultValue: false) + bool hasCompletedIsarDownloadsMigration; + static Future create() async { final internalSongDir = await getInternalSongDir(); final downloadLocation = DownloadLocation.create( @@ -580,6 +584,7 @@ class DownloadStub { required this.type, required this.jsonItem, required this.isarId, + required this.name, }); factory DownloadStub.fromItem({ @@ -593,7 +598,8 @@ class DownloadStub { id: id, isarId: getHash(id, type), jsonItem: jsonEncode(item.toJson()), - type: type); + type: type, + name: (type == DownloadItemType.image) ? "Image for ${item.name}" : item.name ?? id); } factory DownloadStub.fromId({ @@ -602,14 +608,17 @@ class DownloadStub { }) { assert(!type.requiresItem); return DownloadStub._build( - id: id, isarId: getHash(id, type), jsonItem: null, type: type); + id: id, isarId: getHash(id, type), jsonItem: null, type: type, name: id); } final Id isarId; final String id; + final String name; + @Enumerated(EnumType.ordinal) + @Index() final DownloadItemType type; final String? jsonItem; @@ -655,8 +664,11 @@ class DownloadStub { jsonItem: jsonItem, isarId: isarId, jsonMediaSource: null, + name: name, state: DownloadItemState.notDownloaded, downloadLocationId: downloadLocationId, + baseItemtype: baseItem?.type, + baseIndexNumber: baseItem?.indexNumber, ); } } @@ -669,9 +681,12 @@ class DownloadItem extends DownloadStub { required super.type, required super.jsonItem, required super.isarId, + required super.name, required this.jsonMediaSource, required this.state, required this.downloadLocationId, + required this.baseItemtype, + required this.baseIndexNumber, }) : super._build(); final requires = IsarLinks(); @@ -684,6 +699,10 @@ class DownloadItem extends DownloadStub { String? jsonMediaSource; + final String? baseItemtype; + + final int? baseIndexNumber; + @ignore MediaSourceInfo? get mediaSourceInfo => (jsonMediaSource == null) ? null @@ -722,6 +741,11 @@ class DownloadItem extends DownloadStub { } return null; } + + @override + String toString(){ + return "$runtimeType ${type.name} '$name'"; + } } enum DownloadItemType { diff --git a/lib/models/finamp_models.g.dart b/lib/models/finamp_models.g.dart index 870b14497..537c7a936 100644 --- a/lib/models/finamp_models.g.dart +++ b/lib/models/finamp_models.g.dart @@ -532,44 +532,59 @@ const DownloadItemSchema = CollectionSchema( name: r'DownloadItem', id: 3470061580579511306, properties: { - r'downloadId': PropertySchema( + r'baseIndexNumber': PropertySchema( id: 0, + name: r'baseIndexNumber', + type: IsarType.long, + ), + r'baseItemtype': PropertySchema( + id: 1, + name: r'baseItemtype', + type: IsarType.string, + ), + r'downloadId': PropertySchema( + id: 2, name: r'downloadId', type: IsarType.string, ), r'downloadLocationId': PropertySchema( - id: 1, + id: 3, name: r'downloadLocationId', type: IsarType.string, ), r'id': PropertySchema( - id: 2, + id: 4, name: r'id', type: IsarType.string, ), r'jsonItem': PropertySchema( - id: 3, + id: 5, name: r'jsonItem', type: IsarType.string, ), r'jsonMediaSource': PropertySchema( - id: 4, + id: 6, name: r'jsonMediaSource', type: IsarType.string, ), + r'name': PropertySchema( + id: 7, + name: r'name', + type: IsarType.string, + ), r'path': PropertySchema( - id: 5, + id: 8, name: r'path', type: IsarType.string, ), r'state': PropertySchema( - id: 6, + id: 9, name: r'state', type: IsarType.byte, enumMap: _DownloadItemstateEnumValueMap, ), r'type': PropertySchema( - id: 7, + id: 10, name: r'type', type: IsarType.byte, enumMap: _DownloadItemtypeEnumValueMap, @@ -580,7 +595,21 @@ const DownloadItemSchema = CollectionSchema( deserialize: _downloadItemDeserialize, deserializeProp: _downloadItemDeserializeProp, idName: r'isarId', - indexes: {}, + indexes: { + r'type': IndexSchema( + id: 5117122708147080838, + name: r'type', + unique: false, + replace: false, + properties: [ + IndexPropertySchema( + name: r'type', + type: IndexType.value, + caseSensitive: false, + ) + ], + ) + }, links: { r'requires': LinkSchema( id: 2869659933205985607, @@ -609,6 +638,12 @@ int _downloadItemEstimateSize( Map> allOffsets, ) { var bytesCount = offsets.last; + { + final value = object.baseItemtype; + if (value != null) { + bytesCount += 3 + value.length * 3; + } + } { final value = object.downloadId; if (value != null) { @@ -634,6 +669,7 @@ int _downloadItemEstimateSize( bytesCount += 3 + value.length * 3; } } + bytesCount += 3 + object.name.length * 3; { final value = object.path; if (value != null) { @@ -649,14 +685,17 @@ void _downloadItemSerialize( List offsets, Map> allOffsets, ) { - writer.writeString(offsets[0], object.downloadId); - writer.writeString(offsets[1], object.downloadLocationId); - writer.writeString(offsets[2], object.id); - writer.writeString(offsets[3], object.jsonItem); - writer.writeString(offsets[4], object.jsonMediaSource); - writer.writeString(offsets[5], object.path); - writer.writeByte(offsets[6], object.state.index); - writer.writeByte(offsets[7], object.type.index); + writer.writeLong(offsets[0], object.baseIndexNumber); + writer.writeString(offsets[1], object.baseItemtype); + writer.writeString(offsets[2], object.downloadId); + writer.writeString(offsets[3], object.downloadLocationId); + writer.writeString(offsets[4], object.id); + writer.writeString(offsets[5], object.jsonItem); + writer.writeString(offsets[6], object.jsonMediaSource); + writer.writeString(offsets[7], object.name); + writer.writeString(offsets[8], object.path); + writer.writeByte(offsets[9], object.state.index); + writer.writeByte(offsets[10], object.type.index); } DownloadItem _downloadItemDeserialize( @@ -666,18 +705,21 @@ DownloadItem _downloadItemDeserialize( Map> allOffsets, ) { final object = DownloadItem( - downloadLocationId: reader.readStringOrNull(offsets[1]), - id: reader.readString(offsets[2]), + baseIndexNumber: reader.readLongOrNull(offsets[0]), + baseItemtype: reader.readStringOrNull(offsets[1]), + downloadLocationId: reader.readStringOrNull(offsets[3]), + id: reader.readString(offsets[4]), isarId: id, - jsonItem: reader.readStringOrNull(offsets[3]), - jsonMediaSource: reader.readStringOrNull(offsets[4]), - state: _DownloadItemstateValueEnumMap[reader.readByteOrNull(offsets[6])] ?? + jsonItem: reader.readStringOrNull(offsets[5]), + jsonMediaSource: reader.readStringOrNull(offsets[6]), + name: reader.readString(offsets[7]), + state: _DownloadItemstateValueEnumMap[reader.readByteOrNull(offsets[9])] ?? DownloadItemState.notDownloaded, - type: _DownloadItemtypeValueEnumMap[reader.readByteOrNull(offsets[7])] ?? + type: _DownloadItemtypeValueEnumMap[reader.readByteOrNull(offsets[10])] ?? DownloadItemType.collectionDownload, ); - object.downloadId = reader.readStringOrNull(offsets[0]); - object.path = reader.readStringOrNull(offsets[5]); + object.downloadId = reader.readStringOrNull(offsets[2]); + object.path = reader.readStringOrNull(offsets[8]); return object; } @@ -689,21 +731,27 @@ P _downloadItemDeserializeProp

( ) { switch (propertyId) { case 0: - return (reader.readStringOrNull(offset)) as P; + return (reader.readLongOrNull(offset)) as P; case 1: return (reader.readStringOrNull(offset)) as P; case 2: - return (reader.readString(offset)) as P; + return (reader.readStringOrNull(offset)) as P; case 3: return (reader.readStringOrNull(offset)) as P; case 4: - return (reader.readStringOrNull(offset)) as P; + return (reader.readString(offset)) as P; case 5: return (reader.readStringOrNull(offset)) as P; case 6: + return (reader.readStringOrNull(offset)) as P; + case 7: + return (reader.readString(offset)) as P; + case 8: + return (reader.readStringOrNull(offset)) as P; + case 9: return (_DownloadItemstateValueEnumMap[reader.readByteOrNull(offset)] ?? DownloadItemState.notDownloaded) as P; - case 7: + case 10: return (_DownloadItemtypeValueEnumMap[reader.readByteOrNull(offset)] ?? DownloadItemType.collectionDownload) as P; default: @@ -767,6 +815,14 @@ extension DownloadItemQueryWhereSort return query.addWhereClause(const IdWhereClause.any()); }); } + + QueryBuilder anyType() { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + const IndexWhereClause.any(indexName: r'type'), + ); + }); + } } extension DownloadItemQueryWhere @@ -839,10 +895,328 @@ extension DownloadItemQueryWhere )); }); } + + QueryBuilder typeEqualTo( + DownloadItemType type) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IndexWhereClause.equalTo( + indexName: r'type', + value: [type], + )); + }); + } + + QueryBuilder typeNotEqualTo( + DownloadItemType type) { + return QueryBuilder.apply(this, (query) { + if (query.whereSort == Sort.asc) { + return query + .addWhereClause(IndexWhereClause.between( + indexName: r'type', + lower: [], + upper: [type], + includeUpper: false, + )) + .addWhereClause(IndexWhereClause.between( + indexName: r'type', + lower: [type], + includeLower: false, + upper: [], + )); + } else { + return query + .addWhereClause(IndexWhereClause.between( + indexName: r'type', + lower: [type], + includeLower: false, + upper: [], + )) + .addWhereClause(IndexWhereClause.between( + indexName: r'type', + lower: [], + upper: [type], + includeUpper: false, + )); + } + }); + } + + QueryBuilder typeGreaterThan( + DownloadItemType type, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IndexWhereClause.between( + indexName: r'type', + lower: [type], + includeLower: include, + upper: [], + )); + }); + } + + QueryBuilder typeLessThan( + DownloadItemType type, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IndexWhereClause.between( + indexName: r'type', + lower: [], + upper: [type], + includeUpper: include, + )); + }); + } + + QueryBuilder typeBetween( + DownloadItemType lowerType, + DownloadItemType upperType, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IndexWhereClause.between( + indexName: r'type', + lower: [lowerType], + includeLower: includeLower, + upper: [upperType], + includeUpper: includeUpper, + )); + }); + } } extension DownloadItemQueryFilter on QueryBuilder { + QueryBuilder + baseIndexNumberIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'baseIndexNumber', + )); + }); + } + + QueryBuilder + baseIndexNumberIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'baseIndexNumber', + )); + }); + } + + QueryBuilder + baseIndexNumberEqualTo(int? value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'baseIndexNumber', + value: value, + )); + }); + } + + QueryBuilder + baseIndexNumberGreaterThan( + int? value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'baseIndexNumber', + value: value, + )); + }); + } + + QueryBuilder + baseIndexNumberLessThan( + int? value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'baseIndexNumber', + value: value, + )); + }); + } + + QueryBuilder + baseIndexNumberBetween( + int? lower, + int? upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'baseIndexNumber', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } + + QueryBuilder + baseItemtypeIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'baseItemtype', + )); + }); + } + + QueryBuilder + baseItemtypeIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'baseItemtype', + )); + }); + } + + QueryBuilder + baseItemtypeEqualTo( + String? value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'baseItemtype', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + baseItemtypeGreaterThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'baseItemtype', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + baseItemtypeLessThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'baseItemtype', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + baseItemtypeBetween( + String? lower, + String? upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'baseItemtype', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + baseItemtypeStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'baseItemtype', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + baseItemtypeEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'baseItemtype', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + baseItemtypeContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'baseItemtype', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + baseItemtypeMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'baseItemtype', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + baseItemtypeIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'baseItemtype', + value: '', + )); + }); + } + + QueryBuilder + baseItemtypeIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'baseItemtype', + value: '', + )); + }); + } + QueryBuilder downloadIdIsNull() { return QueryBuilder.apply(this, (query) { @@ -1645,6 +2019,140 @@ extension DownloadItemQueryFilter }); } + QueryBuilder nameEqualTo( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'name', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + nameGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'name', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder nameLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'name', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder nameBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'name', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + nameStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'name', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder nameEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'name', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder nameContains( + String value, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'name', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder nameMatches( + String pattern, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'name', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + nameIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'name', + value: '', + )); + }); + } + + QueryBuilder + nameIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'name', + value: '', + )); + }); + } + QueryBuilder pathIsNull() { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(const FilterCondition.isNull( @@ -2035,6 +2543,33 @@ extension DownloadItemQueryLinks extension DownloadItemQuerySortBy on QueryBuilder { + QueryBuilder + sortByBaseIndexNumber() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'baseIndexNumber', Sort.asc); + }); + } + + QueryBuilder + sortByBaseIndexNumberDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'baseIndexNumber', Sort.desc); + }); + } + + QueryBuilder sortByBaseItemtype() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'baseItemtype', Sort.asc); + }); + } + + QueryBuilder + sortByBaseItemtypeDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'baseItemtype', Sort.desc); + }); + } + QueryBuilder sortByDownloadId() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'downloadId', Sort.asc); @@ -2100,6 +2635,18 @@ extension DownloadItemQuerySortBy }); } + QueryBuilder sortByName() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'name', Sort.asc); + }); + } + + QueryBuilder sortByNameDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'name', Sort.desc); + }); + } + QueryBuilder sortByPath() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'path', Sort.asc); @@ -2139,6 +2686,33 @@ extension DownloadItemQuerySortBy extension DownloadItemQuerySortThenBy on QueryBuilder { + QueryBuilder + thenByBaseIndexNumber() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'baseIndexNumber', Sort.asc); + }); + } + + QueryBuilder + thenByBaseIndexNumberDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'baseIndexNumber', Sort.desc); + }); + } + + QueryBuilder thenByBaseItemtype() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'baseItemtype', Sort.asc); + }); + } + + QueryBuilder + thenByBaseItemtypeDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'baseItemtype', Sort.desc); + }); + } + QueryBuilder thenByDownloadId() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'downloadId', Sort.asc); @@ -2216,6 +2790,18 @@ extension DownloadItemQuerySortThenBy }); } + QueryBuilder thenByName() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'name', Sort.asc); + }); + } + + QueryBuilder thenByNameDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'name', Sort.desc); + }); + } + QueryBuilder thenByPath() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'path', Sort.asc); @@ -2255,6 +2841,20 @@ extension DownloadItemQuerySortThenBy extension DownloadItemQueryWhereDistinct on QueryBuilder { + QueryBuilder + distinctByBaseIndexNumber() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'baseIndexNumber'); + }); + } + + QueryBuilder distinctByBaseItemtype( + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'baseItemtype', caseSensitive: caseSensitive); + }); + } + QueryBuilder distinctByDownloadId( {bool caseSensitive = true}) { return QueryBuilder.apply(this, (query) { @@ -2292,6 +2892,13 @@ extension DownloadItemQueryWhereDistinct }); } + QueryBuilder distinctByName( + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'name', caseSensitive: caseSensitive); + }); + } + QueryBuilder distinctByPath( {bool caseSensitive = true}) { return QueryBuilder.apply(this, (query) { @@ -2320,6 +2927,18 @@ extension DownloadItemQueryProperty }); } + QueryBuilder baseIndexNumberProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'baseIndexNumber'); + }); + } + + QueryBuilder baseItemtypeProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'baseItemtype'); + }); + } + QueryBuilder downloadIdProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'downloadId'); @@ -2352,6 +2971,12 @@ extension DownloadItemQueryProperty }); } + QueryBuilder nameProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'name'); + }); + } + QueryBuilder pathProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'path'); diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index ac1ffa495..bd2048098 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -50,11 +50,11 @@ class _AlbumScreenState extends State { // The downloadedParent won't be null here if we've already // navigated to it in offline mode - final downloadedParent = isarDownloads.getMetadataDownload(parent); + final downloadedParent = isarDownloads.getMetadataDownload(parent)!; final downloadChildren = isarDownloads.getCollectionSongs(parent); return AlbumScreenContent( - parent: downloadedParent!.baseItem!, + parent: downloadedParent.baseItem!, children:downloadChildren.map((e) => e.baseItem!).toList(), ); } else { diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index cde5d8dd1..aa65b7f0e 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -43,7 +43,6 @@ class ArtistScreen extends StatelessWidget { parentItem: artist, isFavourite: false, sortBy: SortBy.premiereDate, - albumArtist: artist.name, ), bottomNavigationBar: const NowPlayingBar(), ); diff --git a/lib/screens/downloads_error_screen.dart b/lib/screens/downloads_error_screen.dart index 1af916764..530840ee7 100644 --- a/lib/screens/downloads_error_screen.dart +++ b/lib/screens/downloads_error_screen.dart @@ -24,7 +24,8 @@ class DownloadsErrorScreen extends StatelessWidget { final scaffoldMessenger = ScaffoldMessenger.of(context); final appLocalizations = AppLocalizations.of(context); - final redownloaded = await downloadsHelper.redownloadFailed(); + final redownloaded = 0; //await downloadsHelper.redownloadFailed(); + //TODO decide what to do here. Do we want to just merge this into sync? scaffoldMessenger.showSnackBar( SnackBar( diff --git a/lib/screens/downloads_screen.dart b/lib/screens/downloads_screen.dart index a462f9097..4e1e21e43 100644 --- a/lib/screens/downloads_screen.dart +++ b/lib/screens/downloads_screen.dart @@ -19,7 +19,7 @@ class DownloadsScreen extends StatelessWidget { title: Text(AppLocalizations.of(context)!.downloads), actions: const [ SyncDownloadedPlaylistsButton(), - DownloadMissingImagesButton(), // TODO replace with somthing actually usefull. + DownloadMissingImagesButton(), DownloadErrorScreenButton() ], ), diff --git a/lib/services/audio_service_helper.dart b/lib/services/audio_service_helper.dart index 787f5e9bb..1e7f64125 100644 --- a/lib/services/audio_service_helper.dart +++ b/lib/services/audio_service_helper.dart @@ -119,10 +119,15 @@ class AudioServiceHelper { // This is a bit inefficient since we have to get all of the songs and // shuffle them before making a sublist, but I couldn't think of a better // way. - items = (await _isarDownloader.getAllSongs( - limit: FinampSettingsHelper.finampSettings.songShuffleItemCount)) + items = (await _isarDownloader.getAllSongs()) .map((e) => e.baseItem!) .toList(); + items.shuffle(); + if (items.length - 1 > + FinampSettingsHelper.finampSettings.songShuffleItemCount) { + items = items.sublist( + 0, FinampSettingsHelper.finampSettings.songShuffleItemCount); + } } else { // If online, get all audio items from the user's view items = await _jellyfinApiHelper.getItems( diff --git a/lib/services/downloads_helper.dart b/lib/services/downloads_helper.dart index 77e960373..ce8d0866e 100644 --- a/lib/services/downloads_helper.dart +++ b/lib/services/downloads_helper.dart @@ -18,53 +18,10 @@ import '../models/jellyfin_models.dart'; import '../models/finamp_models.dart'; class DownloadsHelper { - List queue = []; - final _jellyfinApiData = GetIt.instance(); - final _finampUserHelper = GetIt.instance(); - final _downloadedItemsBox = Hive.box("DownloadedItems"); - final _downloadedParentsBox = Hive.box("DownloadedParents"); - final _downloadIdsBox = Hive.box("DownloadIds"); - final _downloadedImagesBox = Hive.box("DownloadedImages"); - final _downloadedImageIdsBox = Hive.box("DownloadedImageIds"); final _downloadsLogger = Logger("DownloadsHelper"); - List? _downloadedParentsCache; - - Iterable get _downloadedParents => - _downloadedParentsCache ?? _loadSortedDownloadedParents(); - - DownloadsHelper() { - _downloadedParentsBox.watch().listen((event) { - _downloadedParentsCache = null; - }); - } - - List _loadSortedDownloadedParents() { - return _downloadedParentsCache = _downloadedParentsBox.values.toList() - ..sort((a, b) { - final nameA = a.item.name; - final nameB = b.item.name; - - return nameA != null && nameB != null - ? nameA.toLowerCase().compareTo(nameB.toLowerCase()) - : 0; - }); - } - - /// Deletes an image, without checking if anything else depends on it first. - Future _deleteImage(DownloadedImage downloadedImage) async { - _downloadsLogger - .info("Deleting ${downloadedImage.downloadId} from flutter_downloader"); - - _downloadedImagesBox.delete(downloadedImage.id); - _downloadedImageIdsBox.delete(downloadedImage.downloadId); - - await FlutterDownloader.remove( - taskId: downloadedImage.downloadId, - shouldDeleteContent: true, - ); - } + DownloadsHelper(); Future?> getIncompleteDownloads() async { try { @@ -88,27 +45,11 @@ class DownloadsHelper { } } - DownloadedSong? _getDownloadedSong(String id) { - try { - return _downloadedItemsBox.get(id); - } catch (e) { - _downloadsLogger.severe(e); - rethrow; - } - } - - DownloadedParent? _getDownloadedParent(String id) { - try { - return _downloadedParentsBox.get(id); - } catch (e) { - _downloadsLogger.severe(e); - rethrow; - } - } - /// Checks if a DownloadedSong is actually downloaded, and fixes common issues /// related to downloads (such as changed appdirs). Returns true if the song /// is downloaded, and false otherwise. + /// TODO is this just about not using relative paths before? images skips it. + /// what does this do extra? Future _verifyDownloadedSong(DownloadedSong downloadedSong) async { if (downloadedSong.downloadLocation == null) { _downloadsLogger.warning( @@ -142,7 +83,7 @@ class DownloadsHelper { from: downloadedSong.downloadLocation!.path); downloadedSong.isPathRelative = true; - //teardown + //teardown - this just updates with new path info. //addDownloadedSong(downloadedSong); } else { // Loop through all download locations. If we don't find one, assume the @@ -303,319 +244,4 @@ class DownloadsHelper { } } } - - /// Redownloads failed items. This is done by deleting the old downloads and - /// creating new ones with the same settings. Returns number of songs - /// redownloaded - Future redownloadFailed() async { - final failedDownloadTasks = - await getDownloadsWithStatus(DownloadTaskStatus.failed); - - if (failedDownloadTasks?.isEmpty ?? true) { - _downloadsLogger - .info("Failed downloads list is empty -> not redownloading anything"); - return 0; - } - - int redownloadCount = 0; - Map> parentItems = {}; - List deleteFutures = []; - List downloadedSongs = []; - - for (DownloadTask downloadTask in failedDownloadTasks!) { - DownloadedSong? downloadedSong; - // teardown - //getJellyfinItemFromDownloadId(downloadTask.taskId); - - if (downloadedSong == null) { - _downloadsLogger.info("Could not get Jellyfin item for failed task"); - continue; - } - - _downloadsLogger.info( - "Redownloading item ${downloadedSong.song.id} (${downloadedSong.song.name})"); - - downloadedSongs.add(downloadedSong); - - List parents = downloadedSong.requiredBy; - for (String parent in parents) { - // We don't specify deletedFor here because it could cause the parent - // to get deleted - // teardown - //deleteFutures - // .add(deleteDownloads(jellyfinItemIds: [downloadedSong.song.id])); - - if (parentItems[downloadedSong.song.id] == null) { - parentItems[downloadedSong.song.id] = []; - } - - parentItems[downloadedSong.song.id]! - .add(await _jellyfinApiData.getItemById(parent)); - } - } - - await Future.wait(deleteFutures); - - // Removed by teardown - /*for (final downloadedSong in downloadedSongs) { - final parents = parentItems[downloadedSong.song.id]; - - if (parents == null) { - _downloadsLogger.warning( - "Item ${downloadedSong.song.name} (${downloadedSong.song.id}) has no parent items, skipping"); - continue; - } - - for (final parent in parents) { - // We can't await all the downloads asynchronously as it could mess - // with setting up parents again - await addDownloads( - items: [downloadedSong.song], - parent: parent, - useHumanReadableNames: downloadedSong.useHumanReadableNames, - downloadLocation: downloadedSong.downloadLocation!, - viewId: downloadedSong.viewId, - ); - redownloadCount++; - } - }*/ - - return redownloadCount; - } - - /// Migrates id-based images to blurhash-based images (for 0.6.15). Should - /// only be run if a migration has not been performed. - Future migrateBlurhashImages() async { - final Map imageMap = {}; - - _downloadsLogger.info("Performing image blurhash migration"); - - // Get a map to link blurhashes to images. This will be the list of images - // we keep. - for (final item in _downloadedItems) { - final image = _downloadedImagesBox.get(item.song.id); - - if (image != null && item.song.blurHash != null) { - imageMap[item.song.blurHash!] = image; - } - } - - // Do above, but for parents. - for (final parent in _downloadedParents) { - final image = _downloadedImagesBox.get(parent.item.id); - - if (image != null && parent.item.blurHash != null) { - imageMap[parent.item.blurHash!] = image; - } - } - - final imagesToKeep = imageMap.values.toSet(); - - // Get a list of all images not in the keep set - final imagesToDelete = _downloadedImages - .where((element) => !imagesToKeep.contains(element)) - .toList(); - - for (final image in imagesToDelete) { - final song = _getDownloadedSong(image.requiredBy.first); - - if (song != null) { - final blurHash = song.song.blurHash; - - imageMap[blurHash]?.requiredBy.addAll(image.requiredBy); - } - } - - // Go through each requiredBy and remove duplicates. We also set the image's - // id to the blurhash. - for (final imageEntry in imageMap.entries) { - final image = imageEntry.value; - - image.requiredBy = image.requiredBy.toSet().toList(); - _downloadsLogger.warning(image.requiredBy); - - image.id = imageEntry.key; - - imageMap[imageEntry.key] = image; - } - - // Sanity check to make sure we haven't double counted/missed an image. - final imagesCount = imagesToKeep.length + imagesToDelete.length; - if (imagesCount != _downloadedImages.length) { - final err = - "Unexpected number of items in images to keep/delete! Expected ${_downloadedImages.length}, got $imagesCount"; - _downloadsLogger.severe(err); - throw err; - } - - // Delete all images. - await Future.wait(imagesToDelete.map((e) => _deleteImage(e))); - - // Clear out the images box and put the kept images back in - await _downloadedImagesBox.clear(); - await _downloadedImagesBox.putAll(imageMap); - - // Do the same, but with the downloadId mapping - await _downloadedImageIdsBox.clear(); - await _downloadedImageIdsBox.putAll( - imageMap.map((key, value) => MapEntry(value.downloadId, value.id))); - - _downloadsLogger.info("Image blurhash migration complete."); - _downloadsLogger.info("${imagesToDelete.length} duplicate images deleted."); - } - - /// Fixes DownloadedImage IDs created by the migration in 0.6.15. In it, - /// migrated images did not have their IDs set to the blurhash. This function - /// sets every image's ID to its blurhash. This function should only be run - /// once, only when required (i.e., upgrading from 0.6.15). In theory, running - /// it on an unaffected database should do nothing, but there's no point doing - /// redundant migrations. - Future fixBlurhashMigrationIds() async { - _downloadsLogger.info("Fixing blurhash migration IDs from 0.6.15"); - - final List images = []; - - for (final image in _downloadedImages) { - final item = _getDownloadedSong(image.requiredBy.first) ?? - _getDownloadedParent(image.requiredBy.first); - - if (item == null) { - // I should really use error enums when I rip this whole system out - throw "Failed to get item from image during blurhash migration fix!"; - } - - switch (item.runtimeType) { - case DownloadedSong: - image.id = (item as DownloadedSong).song.blurHash!; - break; - case DownloadedParent: - image.id = (item as DownloadedParent).item.blurHash!; - break; - default: - throw "Item was unexpected type! got ${item.runtimeType}. This really shouldn't happen..."; - } - - images.add(image); - } - - await _downloadedImagesBox.clear(); - await _downloadedImagesBox - .putAll(Map.fromEntries(images.map((e) => MapEntry(e.id, e)))); - - await _downloadedImageIdsBox.clear(); - await _downloadedImageIdsBox.putAll( - Map.fromEntries(images.map((e) => MapEntry(e.downloadId, e.id)))); - } - - Iterable get _downloadedItems => _downloadedItemsBox.values; - - Iterable get _downloadedImages => _downloadedImagesBox.values; - - - /// Converts a dart list to a string with the correct SQL syntax - String _dartListToSqlList(List dartList) { - try { - String sqlList = "("; - int i = 0; - for (final element in dartList) { - sqlList += "'${element.toString()}'"; - if (i != (dartList.length - 1)) { - sqlList += ", "; - } - i++; - } - sqlList += ")"; - return sqlList; - } catch (e) { - _downloadsLogger.severe(e); - rethrow; - } - } - - /// Downloads the image for the given item. This function assumes that the - /// given item has an image. If the item does not have an image, the function - /// will throw an assert error. The function will return immediately if an - /// image with the same ID is already downloaded. - /// - /// As of 0.6.15, images are indexed by blurhash to ensure that duplicate - /// images are not downloaded (many albums will have an identical image - /// per-song). - Future _downloadImage({ - required BaseItemDto item, - required Directory downloadDir, - required DownloadLocation downloadLocation, - }) async { - assert(item.blurHash != null); - - if (_downloadedImagesBox.containsKey(item.blurHash)) return; - - final imageUrl = _jellyfinApiData.getImageUrl( - item: item, - // Download original file - quality: null, - format: null, - ); - final tokenHeader = _jellyfinApiData.getTokenHeader(); - final relativePath = - path_helper.relative(downloadDir.path, from: downloadLocation.path); - - // We still use imageIds for filenames despite switching to blurhashes as - // blurhashes can include characters that filesystems don't support - final fileName = item.imageId; - - final imageDownloadId = await FlutterDownloader.enqueue( - url: imageUrl.toString(), - savedDir: downloadDir.path, - headers: { - if (tokenHeader != null) "X-Emby-Token": tokenHeader, - }, - fileName: fileName, - openFileFromNotification: false, - showNotification: false, - ); - - if (imageDownloadId == null) { - _downloadsLogger.severe( - "Adding image download for ${item.blurHash} failed! downloadId is null. This only really happens if something goes horribly wrong with flutter_downloader's platform interface. This should never happen..."); - } - - final imageInfo = DownloadedImage.create( - id: item.blurHash!, - downloadId: imageDownloadId!, - path: path_helper.join(relativePath, fileName), - requiredBy: [item.id], - downloadLocationId: downloadLocation.id, - ); - - _addDownloadImageToDownloadedImages(imageInfo); - _downloadedImageIdsBox.put(imageDownloadId, imageInfo.id); - } - - /// Adds a [DownloadedImage] to the DownloadedImages box - void _addDownloadImageToDownloadedImages(DownloadedImage downloadedImage) { - _downloadedImagesBox.put(downloadedImage.id, downloadedImage); - } - - /// Get the download directory for the given item. Will create the directory - /// if it doesn't exist. - Future _getDownloadDirectory({ - required BaseItemDto item, - required Directory downloadBaseDir, - required bool useHumanReadableNames, - }) async { - late Directory directory; - - if (useHumanReadableNames) { - directory = - Directory(path_helper.join(downloadBaseDir.path, item.albumArtist)); - } else { - directory = Directory(downloadBaseDir.path); - } - - if (!await directory.exists()) { - await directory.create(); - } - - return directory; - } } diff --git a/lib/services/finamp_settings_helper.dart b/lib/services/finamp_settings_helper.dart index b8722c6f3..f4a7a8c2e 100644 --- a/lib/services/finamp_settings_helper.dart +++ b/lib/services/finamp_settings_helper.dart @@ -208,6 +208,15 @@ class FinampSettingsHelper { .put("FinampSettings", finampSettingsTemp); } + static void setHasCompletedIsarDownloadsMigration( + bool hasCompletedIsarDownloadsMigration) { + FinampSettings finampSettingsTemp = finampSettings; + finampSettingsTemp.hasCompletedIsarDownloadsMigration = + hasCompletedIsarDownloadsMigration; + Hive.box("FinampSettings") + .put("FinampSettings", finampSettingsTemp); + } + static void setTabOrder(int index, TabContentType tabContentType) { FinampSettings finampSettingsTemp = finampSettings; finampSettingsTemp.tabOrder[index] = tabContentType; diff --git a/lib/services/isar_downloads.dart b/lib/services/isar_downloads.dart index 33519aa0d..889de0bcc 100644 --- a/lib/services/isar_downloads.dart +++ b/lib/services/isar_downloads.dart @@ -4,6 +4,7 @@ import 'package:collection/collection.dart'; import 'package:flutter_downloader/flutter_downloader.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:get_it/get_it.dart'; +import 'package:hive/hive.dart'; import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:permission_handler/permission_handler.dart'; @@ -15,8 +16,8 @@ import 'download_update_stream.dart'; import 'finamp_user_helper.dart'; import 'jellyfin_api_helper.dart'; -final downloadStatusProvider = - StreamProvider.family((ref, stub) { +final downloadStatusProvider = StreamProvider.family + .autoDispose((ref, stub) { final isar = GetIt.instance(); return isar.downloadItems .watchObject(stub.isarId, fireImmediately: true) @@ -51,8 +52,6 @@ class IsarDownloads { } await _isar.downloadItems.putAll(listeners); }); - - //TODO propogate up to set parent download state to downloading/failed/complete }); } @@ -92,7 +91,6 @@ class IsarDownloads { } } if (idsToQuery.isNotEmpty) { - // TODO error handling so we can keep syncing other items List items = await _jellyfinApiData.getItems(itemIds: idsToQuery) ?? []; output.addAll(items.map((e) => DownloadStub.fromItem( @@ -120,16 +118,21 @@ class IsarDownloads { children.add( DownloadStub.fromItem(type: DownloadItemType.image, item: item)); } - List? items = await _jellyfinApiData.getItems( - // TODO error handling so we can keep syncing other items - parentItem: item) ?? - []; - for (var child in items) { - children.add( - DownloadStub.fromItem(type: DownloadItemType.song, item: child)); + // TODO save off playlist order info somehow. Should be present in parentIndexnumber, or call with sort option. + try { + List items = + await _jellyfinApiData.getItems(parentItem: item) ?? []; + for (var child in items) { + // TODO allow non-song children. This might let us download and update entire libraries or other stuff. + // TODO this gets a song/album mix when run for artist/genre. Filter it in some way. + // TODO do we want artist -> songs or artist -> albums -> songs + children.add(DownloadStub.fromItem( + type: DownloadItemType.song, item: child)); + } + } catch (e) { + _downloadsLogger.info("Error downloading children: $e"); + updateChildren = false; } - // TODO remove unless we start storing orders here - // Make sure playlists are accounted for if doing so - songs don't backlink them children.add(DownloadStub.fromItem( type: DownloadItemType.collectionInfo, item: item)); case DownloadItemType.song: @@ -145,7 +148,12 @@ class IsarDownloads { if (item.albumId != null) { collectionIds.add(item.albumId!); } - children.addAll(await _getCollectionInfo(collectionIds)); + try { + children.addAll(await _getCollectionInfo(collectionIds)); + } catch (e) { + _downloadsLogger.info("Error downloading metadata: $e"); + updateChildren = false; + } case DownloadItemType.image: break; case DownloadItemType.anchor: @@ -160,47 +168,38 @@ class IsarDownloads { } } - // oldChildren: outdated linked children - // existingChildren: new children already in database - // missingChildren: children to be added to database + links - // exccessChildren: children to be removed from links - // otherChildren: children to be added to links that are already in database - // addedChildren: all children to be added to links - Set excessChildren = {}; + Set childrenToUnlink = {}; DownloadLocation? downloadLocation; if (updateChildren) { - //TODO update core item with latest + //TODO update core item with latest? + // TODO update child ordering once determined await _isar.writeTxn(() async { - // TODO clean up this mess - // We don't need up-to-date item to run update - // It should be fine to delete and re-add. - // Use query language to filter - // Overwriting item doesn't affect links. DownloadItem? canonParent = await _isar.downloadItems.get(parent.isarId); if (canonParent == null) { - throw StateError( - "_syncDownload ccalled on missing node ${parent.id}"); + throw StateError("_syncDownload called on missing node ${parent.id}"); } downloadLocation = canonParent.downloadLocation; - var oldChildren = - (await canonParent.requires.filter().findAll()).toSet(); - var nullableExistingChildren = (await _isar.downloadItems - .getAll(children.map((e) => e.isarId).toList())) - .toSet(); - nullableExistingChildren.remove(null); - var existingChildren = nullableExistingChildren.cast(); - var missingChildren = children - .difference(existingChildren) - .map((e) => e.asItem(downloadLocation?.id)) - .toSet(); - excessChildren = oldChildren.difference(children); - var otherChildren = existingChildren.difference(oldChildren); - var addedChildren = missingChildren.union(otherChildren); - await _isar.downloadItems.putAll(missingChildren.toList()); - await canonParent.requires - .update(link: addedChildren, unlink: excessChildren); + var oldChildren = await canonParent.requires.filter().findAll(); + // anyOf filter allows all objects when given empty list, but we want no objects + var childrenToLink = (children.isEmpty) + ? [] + : await _isar.downloadItems + .where() + .anyOf(children.map((e) => e.isarId), + (q, int id) => q.isarIdEqualTo(id)) + .findAll(); + var childrenToPutAndLink = children + .difference(childrenToLink.toSet()) + .map((e) => e.asItem(downloadLocation?.id)); + childrenToUnlink = oldChildren.toSet().difference(children); + assert((childrenToLink + childrenToPutAndLink.toList()).length == + children.length); + await _isar.downloadItems.putAll(childrenToPutAndLink.toList()); + await canonParent.requires.update( + link: childrenToLink + childrenToPutAndLink.toList(), + unlink: childrenToUnlink); }); } else { await _isar.txn(() async { @@ -224,7 +223,7 @@ class IsarDownloads { for (var child in children) { await _syncDownload(child, completed); } - for (var child in excessChildren) { + for (var child in childrenToUnlink) { await _syncDelete(child); } } @@ -330,21 +329,24 @@ class IsarDownloads { switch (canonItem.state) { case DownloadItemState.complete: - return; // TODO maybe run verify here? + return; case DownloadItemState.notDownloaded: break; case DownloadItemState.downloading: - return; //TODO figure out what to do here - see if it failed somehow? Check task status + return; //TODO run update from taskstatus, recurse if changed case DownloadItemState.failed: await _deleteDownload(canonItem); case DownloadItemState.deleting: + // If items get stuck in this state, repairAllDownloads/verifyDownload should fix it. _downloadsLogger.info( "Could not download item ${item.id}, it is currently being deleted."); - // TODO get out of this state if error while deleting somehow - during full cleanup sweep? return; case DownloadItemState.paused: - // TODO: I have no idea what to do about this. + // TODO: I have no idea what to do about this. Rectify in cleanup sweep? } + + //TODO verify we don't have a downloadID if proceeding - may need to refetch because it may have updated. + switch (canonItem.type) { case DownloadItemType.song: return _downloadSong(canonItem, downloadLocation); @@ -513,42 +515,132 @@ class IsarDownloads { // TODO verify files are actually gone? await _isar.writeTxn(() async { - // TODO reverify we are in deleting state - item.state = DownloadItemState.notDownloaded; - item.downloadId = null; - await _isar.downloadItems.put(item); + var transactionItem = await _isar.downloadItems.get(item.isarId); + if (transactionItem?.state != DownloadItemState.deleting) { + return; + } + transactionItem!.state = DownloadItemState.notDownloaded; + transactionItem.downloadId = null; + await _isar.downloadItems.put(transactionItem); }); } - Future verifyDownload(DownloadStub stub) async { - return true; // TODO implement + Future repairAllDownloads() async { + // TODO for all completed, run verifyDownload + await resyncAll(); + // TODO for all global run _syncdelete + // TODO get all files in internal directory into list + // for all global + // remove files from list + // delete all files we couldn't link to metadata + } + + Future verifyDownload(DownloadItem item) async { + return true; // TODO do whatever happens in download helper to verify downloads working + // Definetly need to resync status to flutter_downloader's taskstatus/actual file presence. + } + + // TODO need some sort of stream so that musicScreen updates as downloads come in. + + // - first go through boxes and create nodes for all downloaded images/songs + // then go through all downloaded parents and create anchor-attached nodes, and stitch to children/image. + // then run standard verify all command - if it fails due to networking the + Future migrateFromHive() async { + await _migrateImages(); + await _migrateSongs(); + await _migrateParents(); + try { + await repairAllDownloads(); + } catch (error) { + _downloadsLogger.severe("Error $error in hive migration downloads repair."); + // TODO this should still be fine, the user can re-run verify manually later. + // TODO we should display this somehow. + } + //TODO decide if we want to delete metadata here } - // TODO add listener to download events - currently irregular, probably a flutter_downloader thing. - // TODO - include way to mark parent as downloaded if all children complete and non-download type. + Future _migrateImages() async { + final downloadedItemsBox = Hive.box("DownloadedItems"); + final downloadedParentsBox = Hive.box("DownloadedParents"); + final downloadedImagesBox = Hive.box("DownloadedImages"); + + List nodes = []; + + for (final image in downloadedImagesBox.values) { + BaseItemDto baseItem; + var hiveSong = downloadedItemsBox.get(image.requiredBy.first); + if (hiveSong != null) { + baseItem = hiveSong.song; + }else{ + var hiveParent = downloadedParentsBox.get(image.requiredBy.first); + if (hiveParent != null) { + baseItem = hiveParent.item; + }else{ + _downloadsLogger.severe("Could not find item associated with image during migration to isar."); + continue; + } + } + + var isarItem = DownloadStub.fromItem(type: DownloadItemType.image, item: baseItem).asItem(image.downloadLocationId); + isarItem.downloadId = image.downloadId; + isarItem.path = image.path; + isarItem.state = DownloadItemState.downloading; + nodes.add(isarItem); + } - // TODO design accessing API, begin moving widgets. - // need to analyze existing accessors more. - // need item watcher for download/download status buttons- check how they do delete vs. sync - // need some way to get list of items by type - // need to get exact song/image items + await _isar.writeTxn(() async { + await _isar.downloadItems.putAll(nodes); + }); + } - // TODO think about downloader migration + Future _migrateSongs() async { + final downloadedItemsBox = Hive.box("DownloadedItems"); - // TODO add sync all method that syncs anchor + List nodes = []; - // TODO add verify downloads method - // does whatever happens in download helper to verify downloads working - // runs syncDelete on every item in collection - // checks for orphaned files and cleans them up. + for (final song in downloadedItemsBox.values) { + var isarItem = DownloadStub.fromItem(type: DownloadItemType.song, item: song.song).asItem(song.downloadLocationId); + isarItem.downloadId = song.downloadId; + isarItem.path = path_helper.relative( + song.file.path, + from: song.downloadLocation?.path); + isarItem.state = DownloadItemState.downloading; + nodes.add(isarItem); + } - // TODO add migration method - // - first go through boxes and create nodes for all downloaded images/songs - // then go through all downloaded parents and create anchor-attached nodes - maybe delay recursive sync. - // then run full sync from anchor to connect up all nodes - maybe fine to refresh all metadata? - // maybe we should delay until app started? and then trigger standard full sync task to stitch everything? - // then run delete check across all downloaded items - standards download verify as trigerable from settings or something? - // then delete box content, marking migration finished - can we do this before full sync finishes? + await _isar.writeTxn(() async { + await _isar.downloadItems.putAll(nodes); + }); + } + + Future _migrateParents() async { + final downloadedParentsBox = Hive.box("DownloadedParents"); + final downloadedItemsBox = Hive.box("DownloadedItems"); + + for (final parent in downloadedParentsBox.values) { + var songId = parent.downloadedChildren.values.firstOrNull?.id; + if (songId == null){ + _downloadsLogger.severe("Could not find item associated with parent during migration to isar."); + continue; + } + var song = downloadedItemsBox.get(songId); + if (song == null){ + _downloadsLogger.severe("Could not find item associated with parent during migration to isar."); + continue; + } + var isarItem = DownloadStub.fromItem(type: DownloadItemType.collectionDownload, item: parent.item).asItem(song.downloadLocationId); + + await _isar.writeTxn(() async { + await _isar.downloadItems.put(isarItem); + var anchorItem = _anchor.asItem(null); + await _isar.downloadItems.put(anchorItem); + await anchorItem.requires.update(link: [isarItem]); + isarItem.requires.addAll(parent.downloadedChildren.values.map((e) => DownloadStub.fromItem(type: DownloadItemType.song, item: e).asItem(song.downloadLocationId))); + isarItem.requires.add(DownloadStub.fromItem(type: DownloadItemType.image, item: parent.item).asItem(song.downloadLocationId)); + await isarItem.requires.save(); + }); + } + } /// Get the download directory for the given item. Will create the directory /// if it doesn't exist. @@ -573,61 +665,72 @@ class IsarDownloads { return directory; } - List getUserDownloaded() => getAllChildren(_anchor); + List getUserDownloaded() => getVisibleChildren(_anchor); - List getAllChildren(DownloadStub stub) { + List getVisibleChildren(DownloadStub stub) { return _isar.downloadItems + .where() + .typeNotEqualTo(DownloadItemType.collectionInfo) .filter() .requiredBy((q) => q.isarIdEqualTo(stub.isarId)) + .not() + .typeEqualTo(DownloadItemType.image) .findAllSync(); } - // TODO figure out what to do about sort order - // TODO try to make this one query? + // TODO figure out what to do about sort order for playlists + // TODO refactor into async? + // TODO show downloading/failed songs as well as complete? List getCollectionSongs(BaseItemDto item) { - _downloadsLogger.severe("Getting songs for ${item.name}"); - var metadata1 = _isar.downloadItems.getSync( - DownloadStub.fromItem(type: DownloadItemType.collectionInfo, item: item) - .isarId); - var metadata2 = _isar.downloadItems.getSync( - DownloadStub.fromItem(type: DownloadItemType.collectionDownload, item: item) - .isarId); - if (metadata1==null && metadata2==null) return []; - QueryBuilder subQuery(QueryBuilder q) { - if (metadata2 == null){ - return q.requires((q) => q.isarIdEqualTo(metadata1!.isarId)); - } else if (metadata1 == null){ - return q.requiredBy((q) => q.isarIdEqualTo(metadata2!.isarId)); - } else { - return q.requires((q) => q.isarIdEqualTo(metadata1!.isarId)).or().requiredBy((q) => q.isarIdEqualTo(metadata2!.isarId)); - } - - } - return _isar.downloadItems - .filter().group(subQuery) - .typeEqualTo(DownloadItemType.song) - .stateEqualTo(DownloadItemState.complete) - .findAllSync(); - } + var infoId = DownloadStub.getHash(item.id, DownloadItemType.collectionInfo); + var downloadId = + DownloadStub.getHash(item.id, DownloadItemType.collectionDownload); - Future> getAllSongs({int? limit}) { - var query = _isar.downloadItems - .filter() + return _isar.downloadItems + .where() .typeEqualTo(DownloadItemType.song) - .stateEqualTo(DownloadItemState.complete); - if (limit == null) { - return query.findAll(); - } else { - return query.limit(limit).findAll(); - } + .filter() + .group((q) => q + .requires((q) => q.isarIdEqualTo(infoId)) + .or() + .requiredBy((q) => q.isarIdEqualTo(downloadId))) + .stateEqualTo(DownloadItemState.complete) + .sortByBaseIndexNumber() + .thenByName() + .findAllSync(); } + List getAllSongs( + {String? nameFilter, BaseItemDto? relatedTo}) => + _getAll(DownloadItemType.song, DownloadItemState.complete, nameFilter, + null, relatedTo); + // TODO decide if we want all possible collections or just hard-downloaded ones. - Future> getAllCollections() { + List getAllCollections( + {String? nameFilter, + String? baseTypeFilter, + BaseItemDto? relatedTo}) => + _getAll(DownloadItemType.collectionInfo, null, nameFilter, baseTypeFilter, + relatedTo); + + // TODO make async + List _getAll(DownloadItemType type, DownloadItemState? state, + String? nameFilter, String? baseType, BaseItemDto? relatedTo) { + _downloadsLogger.severe("$type $state $nameFilter $baseType"); return _isar.downloadItems + .where() + .typeEqualTo(type) .filter() - .typeEqualTo(DownloadItemType.collectionInfo) - .findAll(); + .optional(state != null, (q) => q.stateEqualTo(state!)) + .optional(nameFilter != null, + (q) => q.nameContains(nameFilter!, caseSensitive: false)) + .optional(baseType != null, (q) => q.baseItemtypeEqualTo(baseType)) + .optional( + relatedTo != null, + (q) => q.requiredBy((q) => q.requires((q) => q.isarIdEqualTo( + DownloadStub.getHash( + relatedTo!.id, DownloadItemType.collectionInfo))))) + .findAllSync(); } DownloadItem? getImageDownload(BaseItemDto item) => getDownload( @@ -652,6 +755,34 @@ class IsarDownloads { } int getDownloadCount(DownloadItemType type) { - return _isar.downloadItems.filter().typeEqualTo(type).countSync(); + return _isar.downloadItems.where().typeEqualTo(type).countSync(); + } + + Future getFileSize(DownloadStub item) async { + var canonItem = await _isar.downloadItems.get(item.isarId); + if (canonItem == null) return 0; + return _getFileSize(canonItem, []); + } + + Future _getFileSize( + DownloadItem item, List completed) async { + if (completed.contains(item)) { + return 0; + } else { + completed.add(item); + } + int size = 0; + for (var child in item.requires.toList()) { + size += await _getFileSize(child, completed); + } + if (item.type == DownloadItemType.song) { + size += item.mediaSourceInfo?.size ?? 0; + } + if (item.type == DownloadItemType.image && item.downloadLocation != null) { + var stat = await item.file.stat(); + size += stat.size; + } + + return size; } } diff --git a/lib/services/jellyfin_api_helper.dart b/lib/services/jellyfin_api_helper.dart index fc3929910..4b758abd6 100644 --- a/lib/services/jellyfin_api_helper.dart +++ b/lib/services/jellyfin_api_helper.dart @@ -2,6 +2,7 @@ import 'package:chopper/chopper.dart'; import 'package:get_it/get_it.dart'; import 'package:logging/logging.dart'; +import 'finamp_settings_helper.dart'; import 'finamp_user_helper.dart'; import 'jellyfin_api.dart'; import '../models/finamp_models.dart'; @@ -47,6 +48,7 @@ class JellyfinApiHelper { // use. return []; } + assert(!FinampSettingsHelper.finampSettings.isOffline); Response response; @@ -248,6 +250,7 @@ class JellyfinApiHelper { /// Gets an item from a user's library. Future getItemById(String itemId) async { + assert(!FinampSettingsHelper.finampSettings.isOffline); final Response response = await jellyfinApi.getItemById( userId: _finampUserHelper.currentUser!.id, itemId: itemId,