Files
Zakaria 1563409cb1
Build / Build for Android (push) Has been cancelled
Build / Build for iOS (push) Has been cancelled
first commit
2026-05-18 14:15:38 -04:00

1181 lines
43 KiB
Dart

import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_downloader/flutter_downloader.dart';
import 'package:get_it/get_it.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as path_helper;
import 'finamp_settings_helper.dart';
import 'finamp_user_helper.dart';
import 'jellyfin_api.dart';
import 'jellyfin_api_helper.dart';
import 'get_internal_song_dir.dart';
import '../models/jellyfin_models.dart';
import '../models/finamp_models.dart';
class DownloadsHelper {
List<String> queue = [];
final _jellyfinApiData = GetIt.instance<JellyfinApiHelper>();
final _finampUserHelper = GetIt.instance<FinampUserHelper>();
final _downloadedItemsBox = Hive.box<DownloadedSong>("DownloadedItems");
final _downloadedParentsBox = Hive.box<DownloadedParent>("DownloadedParents");
final _downloadIdsBox = Hive.box<DownloadedSong>("DownloadIds");
final _downloadedImagesBox = Hive.box<DownloadedImage>("DownloadedImages");
final _downloadedImageIdsBox = Hive.box<String>("DownloadedImageIds");
final _downloadsLogger = Logger("DownloadsHelper");
List<DownloadedParent>? _downloadedParentsCache;
Iterable<DownloadedParent> get downloadedParents =>
_downloadedParentsCache ?? _loadSortedDownloadedParents();
DownloadsHelper() {
_downloadedParentsBox.watch().listen((event) {
_downloadedParentsCache = null;
});
}
List<DownloadedParent> _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;
});
}
Future<void> addDownloads({
required List<BaseItemDto> items,
required BaseItemDto parent,
required bool useHumanReadableNames,
required DownloadLocation downloadLocation,
/// The view that this download is in. Used for sorting in offline mode.
required String viewId,
}) async {
// Check if we have external storage permission. It's a bit of a hack, but
// we only do this if downloadLocation.deletable is true because if it's
// true, we're downloading to a user location. You wouldn't want the app
// asking for permission when using internal storage.
if (downloadLocation.deletable) {
if (!await Permission.storage.request().isGranted) {
_downloadsLogger.severe("Storage permission is not granted, exiting");
return Future.error(
"Storage permission is required for external storage");
}
}
try {
if (!_downloadedParentsBox.containsKey(parent.id)) {
// If the current album doesn't exist, add the album to the box of albums
_downloadsLogger.info(
"Album ${parent.name} (${parent.id}) not in albums box, adding now.");
_downloadedParentsBox.put(
parent.id,
DownloadedParent(
item: parent, downloadedChildren: {}, viewId: viewId));
}
if (parent.blurHash != null &&
!_downloadedImagesBox.containsKey(parent.blurHash) &&
parent.hasOwnImage) {
_downloadsLogger
.info("Downloading parent image for ${parent.name} (${parent.id}");
final downloadDir = await _getDownloadDirectory(
item: parent,
downloadBaseDir: Directory(downloadLocation.path),
useHumanReadableNames: useHumanReadableNames,
);
await _downloadImage(
item: parent,
downloadDir: downloadDir,
downloadLocation: downloadLocation,
);
}
for (final item in items) {
_downloadsLogger.info("Attempting to download ${item.id}");
if (_downloadedItemsBox.containsKey(item.id)) {
// If the item already exists, add the parent item to its requiredBy field and skip actually downloading the song.
// We also add the item to the downloadedChildren of the parent that we're downloading.
_downloadsLogger.info(
"Item ${item.id} already exists in downloadedItemsBox, adding requiredBy to DownloadedItem and adding to ${parent.id}'s downloadedChildren");
// This is technically nullable but we check if it contains the key
// in order to get to this point.
DownloadedSong itemFromBox = _downloadedItemsBox.get(item.id)!;
itemFromBox.requiredBy.add(parent.id);
addDownloadedSong(itemFromBox);
_addItemToDownloadedAlbum(parent.id, item);
continue;
}
// Base URL shouldn't be null at this point (user has to be logged in
// to get to the point where they can add downloads).
String songUrl =
"${_finampUserHelper.currentUser!.baseUrl}/Items/${item.id}/File";
List<MediaSourceInfo>? mediaSourceInfo =
await _jellyfinApiData.getPlaybackInfo(item.id);
String fileName;
Directory downloadDir = await _getDownloadDirectory(
item: item,
downloadBaseDir: Directory(downloadLocation.path),
useHumanReadableNames: useHumanReadableNames,
);
if (useHumanReadableNames) {
if (mediaSourceInfo == null) {
_downloadsLogger.warning(
"Media source info for ${item.id} returned null, filename may be weird.");
}
// We use a regex to filter out bad characters from song/album names.
fileName =
"${item.album?.replaceAll(RegExp('[/?<>\\:*|"]'), "_")} - ${item.indexNumber ?? 0} - ${item.name?.replaceAll(RegExp('[/?<>\\:*|"]'), "_")}.${mediaSourceInfo?[0].container}";
} else {
fileName = "${item.id}.${mediaSourceInfo?[0].container}";
downloadDir = Directory(downloadLocation.path);
}
String authHeader = await getAuthHeader();
String? songDownloadId = await FlutterDownloader.enqueue(
url: songUrl,
savedDir: downloadDir.path,
headers: {
"Authorization": authHeader,
},
fileName: fileName,
openFileFromNotification: false,
showNotification: false,
);
if (songDownloadId == null) {
_downloadsLogger.severe(
"Adding download for ${item.id} failed! downloadId is null. This only really happens if something goes horribly wrong with flutter_downloader's platform interface. This should never happen...");
}
DownloadedSong songInfo = DownloadedSong(
song: item,
mediaSourceInfo: mediaSourceInfo![0],
downloadId: songDownloadId!,
requiredBy: [parent.id],
path: path_helper.relative(
path_helper.join(downloadDir.path, fileName),
from: downloadLocation.path),
useHumanReadableNames: useHumanReadableNames,
viewId: viewId,
downloadLocationId: downloadLocation.id,
);
// Adds the current song to the downloaded items box with its media info and download id
addDownloadedSong(songInfo);
// Adds the current song to the parent's DownloadedAlbum
_addItemToDownloadedAlbum(parent.id, item);
// Adds the download id and the item id to the download ids box so that we can track the download id back to the actual song
_downloadIdsBox.put(songDownloadId, songInfo);
// If the item has an blurhash, handle getting/noting the downloaded
// image.
if (item.blurHash != null) {
if (_downloadedImagesBox.containsKey(item.blurHash)) {
_downloadsLogger.info(
"Image ${item.blurHash} already exists in downloadedImagesBox, adding requiredBy to DownloadedImage.");
final downloadedImage = _downloadedImagesBox.get(item.blurHash)!;
downloadedImage.requiredBy.add(item.id);
_addDownloadImageToDownloadedImages(downloadedImage);
} else if (item.hasOwnImage) {
_downloadsLogger.info(
"Downloading image for ${item.name} (${item.id}) as it has its own image");
await _downloadImage(
item: item,
downloadDir: downloadDir,
downloadLocation: downloadLocation,
);
} else if (parent.type != "MusicAlbum") {
_downloadsLogger.info(
"Downloading parent image for ${item.name} (${item.id}) as the parent is not an album but the parent image is not downloaded");
await _downloadImage(
item: item,
downloadDir: downloadDir,
downloadLocation: downloadLocation,
);
}
}
}
} catch (e) {
_downloadsLogger.severe(e);
return Future.error(e);
}
}
/// Gets the download status for the given item ids (Jellyfin item id, not flutter_downloader task id).
Future<List<DownloadTask>?> getDownloadStatus(List<String> itemIds) async {
try {
List<String> downloadIds = [];
for (final itemId in itemIds) {
if (_downloadedItemsBox.containsKey(itemId)) {
// _downloadedItemsBox.get shouldn't return null as an item with the
// key itemId already exists.
downloadIds.add(_downloadedItemsBox.get(itemId)!.downloadId);
}
}
List<DownloadTask>? downloadStatuses =
await FlutterDownloader.loadTasksWithRawQuery(
query:
"SELECT * FROM task WHERE task_id IN ${_dartListToSqlList(downloadIds)}");
return downloadStatuses;
} catch (e) {
_downloadsLogger.severe(e);
return Future.error(e);
}
}
Future<void> removeChildFromParent({required String parentId, required List<String> childIds}) async {
var album = _downloadedParentsBox.get(parentId);
for (String childId in childIds) {
album?.downloadedChildren.removeWhere((key, value) => key == childId);
}
}
Future<void> deleteSong({required String jellyfinItemId}) async {
final List<Future> deleteDownloadFutures = [];
final Map<String, Directory> directoriesToCheck = {};
_downloadsLogger.info("Attempting to delete $jellyfinItemId");
DownloadedSong? downloadedSong = getDownloadedSong(jellyfinItemId);
if (downloadedSong == null) {
_downloadsLogger.info(
"Could not find $jellyfinItemId in downloadedItemsBox, assuming already deleted");
} else {
DownloadedImage? downloadedImage = getDownloadedImage(downloadedSong.song);
downloadedImage?.requiredBy.remove(jellyfinItemId);
if (downloadedSong.requiredBy.isEmpty) {
_downloadsLogger.info(
"Item $jellyfinItemId has no dependencies, deleting files");
_downloadsLogger.info(
"Deleting ${downloadedSong.downloadId} from flutter_downloader");
deleteDownloadFutures.add(FlutterDownloader.remove(
taskId: downloadedSong.downloadId,
shouldDeleteContent: true,
));
await _downloadedItemsBox.delete(jellyfinItemId);
await _downloadIdsBox.delete(downloadedSong.downloadId);
// We only have to care about deleting directories if files are
// stored with human readable file names.
if (downloadedSong.useHumanReadableNames) {
// We use the parent here since downloadedSong.path still includes
// the filename. We assume that downloadedSong.path is not null,
// as if downloadedSong.useHumanReadableNames is true, the path
// would have been set at some point.
Directory songDirectory = downloadedSong.file.parent;
if (!directoriesToCheck.containsKey(songDirectory.path)) {
// Add the directory to the directory map.
// We keep the directories in a map so that we can easily check
// for duplicates.
directoriesToCheck[songDirectory.path] = songDirectory;
}
}
if (downloadedImage?.requiredBy.isEmpty ?? false) {
deleteDownloadFutures.add(_handleDeleteImage(downloadedImage!));
}
}
}
}
/// This function will delete all given jellyfinItemIds regardless if they are still used
/// by other albums/playlists
Future<void> deleteDownloadChildren({required List<String> jellyfinItemIds, String? deletedFor}) async {
final List<Future> deleteDownloadFutures = [];
final Map<String, Directory> directoriesToCheck = {};
for (final jellyfinItemId in jellyfinItemIds) {
_downloadsLogger.info("Attempting to delete $jellyfinItemId");
DownloadedSong? downloadedSong = getDownloadedSong(jellyfinItemId);
if (downloadedSong == null) {
_downloadsLogger.info(
"Could not find $jellyfinItemId in downloadedItemsBox, assuming already deleted");
} else {
DownloadedImage? downloadedImage =
getDownloadedImage(downloadedSong.song);
if (deletedFor != null) {
_downloadsLogger
.info("Removing $deletedFor dependency from $jellyfinItemId, current dependencies ${downloadedSong.requiredBy}");
downloadedSong.requiredBy.removeWhere((item) => item == deletedFor);
}
downloadedImage?.requiredBy.remove(jellyfinItemId);
if (downloadedSong.requiredBy.isEmpty || deletedFor == null) {
_downloadsLogger.info(
"Item $jellyfinItemId has no dependencies or was manually deleted, deleting files");
_downloadsLogger.info(
"Deleting ${downloadedSong.downloadId} from flutter_downloader");
deleteDownloadFutures.add(FlutterDownloader.remove(
taskId: downloadedSong.downloadId,
shouldDeleteContent: true,
));
await _downloadedItemsBox.delete(jellyfinItemId);
await _downloadIdsBox.delete(downloadedSong.downloadId);
if (deletedFor != null) {
DownloadedParent? downloadedAlbumTemp =
_downloadedParentsBox.get(deletedFor);
if (downloadedAlbumTemp != null) {
downloadedAlbumTemp.downloadedChildren.remove(jellyfinItemId);
await _downloadedParentsBox.put(deletedFor, downloadedAlbumTemp);
}
downloadedImage?.requiredBy.remove(deletedFor);
}
// We only have to care about deleting directories if files are
// stored with human readable file names.
if (downloadedSong.useHumanReadableNames) {
// We use the parent here since downloadedSong.path still includes
// the filename. We assume that downloadedSong.path is not null,
// as if downloadedSong.useHumanReadableNames is true, the path
// would have been set at some point.
Directory songDirectory = downloadedSong.file.parent;
if (!directoriesToCheck.containsKey(songDirectory.path)) {
// Add the directory to the directory map.
// We keep the directories in a map so that we can easily check
// for duplicates.
directoriesToCheck[songDirectory.path] = songDirectory;
}
}
}
if (downloadedImage?.requiredBy.isEmpty ?? false) {
deleteDownloadFutures.add(_handleDeleteImage(downloadedImage!));
}
}
}
await Future.wait(deleteDownloadFutures);
for (var element in directoriesToCheck.values) {
// Loop through each directory and check if it's empty. If it is, delete the directory.
if (await element.list().isEmpty) {
_downloadsLogger.info("${element.path} is empty, deleting");
element.delete();
}
}
}
Future<void> deleteDownloadParent({required String deletedFor}) async {
final parentItem = getDownloadedParent(deletedFor)?.item;
if (parentItem != null) {
final downloadedImage = getDownloadedImage(parentItem);
downloadedImage?.requiredBy.remove(deletedFor);
if (downloadedImage != null) {
await _handleDeleteImage(downloadedImage);
}
}
_downloadedParentsBox.delete(deletedFor);
}
/// Deletes download tasks for items with ids in jellyfinItemIds from storage
/// and removes the Hive entries for that download task. If deletedFor is
/// specified, also do checks to delete the parent album. The only time
/// deletedFor is not specified is when a user plays a song that has been
/// manually deleted.
Future<void> deleteParentAndChildDownloads({
required List<String> jellyfinItemIds,
String? deletedFor,
}) async {
try {
await deleteDownloadChildren(jellyfinItemIds: jellyfinItemIds, deletedFor: deletedFor);
if (deletedFor != null) {
await deleteDownloadParent(deletedFor: deletedFor);
}
} catch (e) {
_downloadsLogger.severe(e);
return Future.error(e);
}
}
/// Deletes an image if it no longer has any dependents (requiredBy is empty)
Future<void> _handleDeleteImage(DownloadedImage downloadedImage) async {
if (downloadedImage.requiredBy.isEmpty) {
_downloadsLogger
.info("Image ${downloadedImage.id} has no dependencies, deleting.");
await _deleteImage(downloadedImage);
}
}
/// Deletes an image, without checking if anything else depends on it first.
Future<void> _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,
);
}
/// Calculates the total file size of the given directory.
/// Returns the total file size in bytes.
/// Returns 0 if the directory doesn't exist.
Future<int> getDirSize(Directory directory) async {
try {
// https://stackoverflow.com/questions/57140112/how-to-get-the-size-of-a-directory-including-its-files
if (await directory.exists()) {
int totalSize = 0;
try {
await for (FileSystemEntity entity in directory.list()) {
if (entity is File) {
totalSize += await entity.length();
}
}
} catch (e) {
return Future.error(e);
}
return totalSize;
} else {
return 0;
}
} catch (e) {
_downloadsLogger.severe(e);
return Future.error(e);
}
}
Future<List<DownloadTask>?> getIncompleteDownloads() async {
try {
return await FlutterDownloader.loadTasksWithRawQuery(
query: "SELECT * FROM task WHERE status <> 3");
} catch (e) {
_downloadsLogger.severe(e);
return Future.error(e);
}
}
Future<List<DownloadTask>?> getDownloadsWithStatus(
DownloadTaskStatus downloadTaskStatus) async {
try {
return await FlutterDownloader.loadTasksWithRawQuery(
query:
"SELECT * FROM task WHERE status = ${downloadTaskStatus.value}");
} catch (e) {
_downloadsLogger.severe(e);
return Future.error(e);
}
}
/// Returns the DownloadedSong of the given Flutter Downloader id.
/// Returns null if the item is not found.
DownloadedSong? getJellyfinItemFromDownloadId(String downloadId) {
try {
return _downloadIdsBox.get(downloadId);
} catch (e) {
_downloadsLogger.severe(e);
rethrow;
}
}
/// Returns the DownloadedImage of the given Flutter Downloader id.
/// Returns null if the item is not found.
DownloadedImage? getDownloadedImageFromDownloadId(String downloadId) {
try {
return _downloadedImagesBox.get(_downloadedImageIdsBox.get(downloadId));
} catch (e) {
_downloadsLogger.severe(e);
rethrow;
}
}
/// Checks if an item with the key albumId exists in downloadedAlbumsBox.
bool isAlbumDownloaded(String albumId) {
try {
return _downloadedParentsBox.containsKey(albumId);
} catch (e) {
_downloadsLogger.severe(e);
rethrow;
}
}
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.
Future<bool> verifyDownloadedSong(DownloadedSong downloadedSong) async {
if (downloadedSong.downloadLocation == null) {
_downloadsLogger.warning(
"Download location for ${downloadedSong.song.id} ${downloadedSong.song.name} returned null, looking for one now");
bool hasFoundLocation = false;
final internalSongDir = await getInternalSongDir();
final potentialSongFile = File(path_helper.join(
internalSongDir.path, path_helper.basename(downloadedSong.path)));
// If the file exists in the (actual, not the potentially incorrect one
// stored) internal song dir, set the download location ID to the internal
// song dir and reset the stored internal song dir if it doesn't exist.
// Also make the song's path relative to this new location.
if (await potentialSongFile.exists()) {
_downloadsLogger.info(
"${downloadedSong.song.id} ${downloadedSong.song.name} exists at default internal song dir location, setting song to internal song dir");
downloadedSong.downloadLocationId =
FinampSettingsHelper.finampSettings.internalSongDir.id;
hasFoundLocation = true;
if (!await Directory(
FinampSettingsHelper.finampSettings.internalSongDir.path)
.exists()) {
await FinampSettingsHelper.resetDefaultDownloadLocation();
}
downloadedSong.path = path_helper.relative(potentialSongFile.path,
from: downloadedSong.downloadLocation!.path);
downloadedSong.isPathRelative = true;
addDownloadedSong(downloadedSong);
} else {
// Loop through all download locations. If we don't find one, assume the
// download location has been deleted.
FinampSettingsHelper.finampSettings.downloadLocationsMap
.forEach((key, value) {
if (downloadedSong.path.contains(value.path)) {
_downloadsLogger.info(
"Found download location (${value.name} ${value.id}), setting location for ${downloadedSong.song.id}");
downloadedSong.downloadLocationId = value.id;
addDownloadedSong(downloadedSong);
hasFoundLocation = true;
}
});
}
if (!hasFoundLocation) {
_downloadsLogger.severe(
"Failed to find download location for ${downloadedSong.song.name} ${downloadedSong.song.id}! The download location may have been deleted.");
// return false;
}
}
return await _verifyDownload(downloadedSong: downloadedSong);
}
Future<bool> verifyDownloadedImage(DownloadedImage downloadedImage) async =>
await _verifyDownload(downloadedImage: downloadedImage);
Future<bool> _verifyDownload(
{DownloadedSong? downloadedSong,
DownloadedImage? downloadedImage}) async {
assert((downloadedSong == null) ^ (downloadedImage == null));
late String id;
late String downloadId;
late File file;
late DownloadLocation downloadLocation; // Checked before this func is run
late bool isPathRelative;
DownloadTask? downloadTask;
if (downloadedSong != null) {
id = downloadedSong.song.id;
downloadId = downloadedSong.downloadId;
file = downloadedSong.file;
downloadLocation = downloadedSong.downloadLocation!;
isPathRelative = downloadedSong.isPathRelative;
downloadTask = await downloadedSong.downloadTask;
} else {
id = downloadedImage!.id;
downloadId = downloadedImage.downloadId;
file = downloadedImage.file;
downloadLocation = downloadedImage.downloadLocation!;
isPathRelative = true;
downloadTask = await downloadedImage.downloadTask;
}
if (downloadTask == null) {
_downloadsLogger.severe(
"Download task list for $downloadId ($id) returned null, assuming item not downloaded");
return false;
}
if (downloadTask.status == DownloadTaskStatus.complete) {
_downloadsLogger.info("Song $id exists offline, using local file");
// Here we check if the file exists. This is important for
// human-readable files, since the user could have deleted the file. iOS
// also likes to move around the documents path after updates for some
// reason.
if (await file.exists()) {
return true;
}
// Songs that don't have a deletable download location (internal storage)
// will be in the internal directory, so we check here first
if (!downloadLocation.deletable) {
_downloadsLogger.warning(
"${file.path} not found! Checking if the document directory has moved.");
final currentDocumentsDirectory =
await getApplicationDocumentsDirectory();
DownloadLocation internalStorageLocation =
FinampSettingsHelper.finampSettings.internalSongDir;
// If the song path doesn't contain the current path, assume the
// path has changed.
if (!file.path.contains(currentDocumentsDirectory.path)) {
_downloadsLogger.warning(
"Item does not contain documents directory, assuming moved.");
if (internalStorageLocation.path !=
path_helper.join(currentDocumentsDirectory.path, "songs")) {
// Append /songs to the documents directory and create the new
// song dir if it doesn't exist for some reason.
final newSongDir = Directory(
path_helper.join(currentDocumentsDirectory.path, "songs"));
_downloadsLogger.warning(
"Difference found in settings documents paths. Changing ${internalStorageLocation.path} to ${newSongDir.path} in settings.");
// Set the new path in FinampSettings.
internalStorageLocation =
await FinampSettingsHelper.resetDefaultDownloadLocation();
}
// If the song's path is not relative, make it relative. This only
// handles songs since all images will have relative paths.
if (!isPathRelative) {
downloadedSong!.path = path_helper.relative(downloadedSong.path,
from: downloadedSong.downloadLocation!.path);
downloadedSong.isPathRelative = true;
addDownloadedSong(downloadedSong);
}
if (await downloadedSong?.file.exists() ??
await downloadedImage!.file.exists()) {
_downloadsLogger
.info("Found item in new path! Everything is fine™");
return true;
} else {
_downloadsLogger.warning(
"$id not found in new path! Assuming that it was deleted before an update.");
}
} else {
_downloadsLogger.warning(
"The stored documents directory and the new one are both the same.");
}
}
// If the function has got to this point, the file was probably deleted.
// If the file was not found, delete it in DownloadsHelper so that it properly shows as deleted.
_downloadsLogger.warning(
"${file.path} not found! Assuming deleted by user. Deleting with DownloadsHelper");
deleteParentAndChildDownloads(
jellyfinItemIds: [id],
);
// If offline, throw an error. Otherwise, return false.
// TODO: This will need changing for #188
if (FinampSettingsHelper.finampSettings.isOffline) {
return Future.error(
"File could not be found. Not falling back to online stream due to offline mode");
} else {
return false;
}
} else {
if (FinampSettingsHelper.finampSettings.isOffline) {
return Future.error(
"Download is not complete, not adding. Wait for all downloads to be complete before playing.");
} else {
return false;
}
}
}
DownloadedImage? getDownloadedImage(BaseItemDto item) {
if (item.blurHash != null) {
return _downloadedImagesBox.get(item.blurHash);
} else {
return null;
}
}
/// Adds a song to the database. If a song with the same ID already exists, it
/// is overwritten.
void addDownloadedSong(DownloadedSong newDownloadedSong) =>
_downloadedItemsBox.put(newDownloadedSong.song.id, newDownloadedSong);
/// Checks all downloaded items/parents for missing images and downloads them.
/// Returns the amount of images downloaded.
Future<int> downloadMissingImages() async {
// Get an iterable of downloaded items where the download has an image but
// that image isn't downloaded
Iterable<DownloadedSong> missingItems = downloadedItems.where((element) =>
element.song.blurHash != null &&
!_downloadedImagesBox.containsKey(element.song.blurHash));
List<Future<bool>> verifyFutures = [];
// We check if the downloaded song is valid because we'll need a download
// location to download its image (download location will be null if made
// before 0.6, and most missing images will be because they were downloaded
// before 0.6)
for (final missingItem in missingItems) {
verifyFutures.add(verifyDownloadedSong(missingItem));
}
List<bool> verifyResults = await Future.wait(verifyFutures);
// If any downloads were invalid, regenerate the iterable
if (verifyResults.contains(false)) {
missingItems = downloadedItems.where((element) =>
element.song.blurHash != null &&
!_downloadedImagesBox.containsKey(element.song.blurHash));
}
final List<Future<void>> downloadFutures = [];
for (final missingItem in missingItems) {
downloadFutures.add(_downloadImage(
item: missingItem.song,
downloadDir: missingItem.file.parent,
downloadLocation: missingItem.downloadLocation!,
));
}
Iterable<DownloadedParent> missingParents = downloadedParents.where(
(element) =>
element.item.blurHash != null &&
!_downloadedImagesBox.containsKey(element.item.blurHash));
verifyFutures = [];
for (final missingParent in missingParents) {
// Since parents don't have their own location/path, we take their first
// child.
final downloadedSong =
getDownloadedSong(missingParent.downloadedChildren.values.first.id);
if (downloadedSong == null) {
_downloadsLogger.warning(
"Failed to get downloaded song for parent ${missingParent.item.name}! This shouldn't happen...");
continue;
}
verifyFutures.add(verifyDownloadedSong(downloadedSong));
}
verifyResults = await Future.wait(verifyFutures);
if (verifyResults.contains(false)) {
missingParents = downloadedParents.where((element) =>
element.item.blurHash != null &&
!_downloadedImagesBox.containsKey(element.item.blurHash));
}
for (final missingParent in missingParents) {
final downloadedSong =
getDownloadedSong(missingParent.downloadedChildren.values.first.id);
if (downloadedSong == null) {
_downloadsLogger.warning(
"Failed to get downloaded song for parent ${missingParent.item.name}! This REALLY shouldn't happen...");
continue;
}
downloadFutures.add(_downloadImage(
item: missingParent.item,
downloadDir: downloadedSong.file.parent,
downloadLocation: downloadedSong.downloadLocation!,
));
}
await Future.wait(downloadFutures);
return downloadFutures.length;
}
/// 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<int> 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<String, List<BaseItemDto>> parentItems = {};
List<Future> deleteFutures = [];
List<DownloadedSong> downloadedSongs = [];
for (DownloadTask downloadTask in failedDownloadTasks!) {
DownloadedSong? downloadedSong =
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<String> parents = downloadedSong.requiredBy;
for (String parent in parents) {
// We don't specify deletedFor here because it could cause the parent
// to get deleted
deleteFutures
.add(deleteParentAndChildDownloads(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);
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<void> migrateBlurhashImages() async {
final Map<String, DownloadedImage> 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<void> fixBlurhashMigrationIds() async {
_downloadsLogger.info("Fixing blurhash migration IDs from 0.6.15");
final List<DownloadedImage> 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<DownloadedSong> get downloadedItems => _downloadedItemsBox.values;
Iterable<DownloadedImage> get downloadedImages => _downloadedImagesBox.values;
ValueListenable<Box<DownloadedSong>> getDownloadedItemsListenable(
{List<String>? keys}) =>
_downloadedItemsBox.listenable(keys: keys);
/// 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;
}
}
/// Adds an item to a DownloadedAlbum's downloadedChildren map
void _addItemToDownloadedAlbum(String albumId, BaseItemDto item) {
try {
DownloadedParent? albumTemp = _downloadedParentsBox.get(albumId);
if (albumTemp == null) {
} else {
albumTemp.downloadedChildren[item.id] = item;
_downloadedParentsBox.put(albumId, albumTemp);
}
} catch (e) {
_downloadsLogger.severe(e);
}
}
/// 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<void> _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 authHeader = await getAuthHeader();
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: {
"Authorization": authHeader,
},
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<Directory> _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;
}
}