immich/mobile/lib/services/download.service.dart
JobiJoba 8733d1e554
feat(mobile): add bulk download functionality (#18878)
* feat(mobile): add bulk download functionality and update UI messages

- Added `downloadAll` method to `IDownloadRepository` and its implementation in `DownloadRepository` to handle multiple asset downloads.
- Implemented `downloadAllAsset` in `DownloadStateNotifier` to trigger bulk downloads.
- Updated `DownloadService` to create download tasks for all selected assets.
- Enhanced UI with new download success and failure messages in `en.json`.
- Added download button to `ControlBottomAppBar` and integrated download functionality in `MultiselectGrid`.

* translations use i18n method t()

* Update mobile/lib/services/download.service.dart

Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>

* fix(mobile): update download logic in DownloadService

- Changed the download method to utilize downloadAll for handling multiple tasks.
- Simplified remoteId check by removing unnecessary condition.

* sort i18n keys

* remove the download signature from interface and logic as we use the downloadAll now

---------

Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>
2025-06-04 09:49:43 -05:00

243 lines
7.3 KiB
Dart

import 'dart:io';
import 'package:background_downloader/background_downloader.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/interfaces/download.interface.dart';
import 'package:immich_mobile/interfaces/file_media.interface.dart';
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
import 'package:immich_mobile/repositories/download.repository.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/download.dart';
import 'package:logging/logging.dart';
final downloadServiceProvider = Provider(
(ref) => DownloadService(
ref.watch(fileMediaRepositoryProvider),
ref.watch(downloadRepositoryProvider),
),
);
class DownloadService {
final IDownloadRepository _downloadRepository;
final IFileMediaRepository _fileMediaRepository;
final Logger _log = Logger("DownloadService");
void Function(TaskStatusUpdate)? onImageDownloadStatus;
void Function(TaskStatusUpdate)? onVideoDownloadStatus;
void Function(TaskStatusUpdate)? onLivePhotoDownloadStatus;
void Function(TaskProgressUpdate)? onTaskProgress;
DownloadService(
this._fileMediaRepository,
this._downloadRepository,
) {
_downloadRepository.onImageDownloadStatus = _onImageDownloadCallback;
_downloadRepository.onVideoDownloadStatus = _onVideoDownloadCallback;
_downloadRepository.onLivePhotoDownloadStatus =
_onLivePhotoDownloadCallback;
_downloadRepository.onTaskProgress = _onTaskProgressCallback;
}
void _onTaskProgressCallback(TaskProgressUpdate update) {
onTaskProgress?.call(update);
}
void _onImageDownloadCallback(TaskStatusUpdate update) {
onImageDownloadStatus?.call(update);
}
void _onVideoDownloadCallback(TaskStatusUpdate update) {
onVideoDownloadStatus?.call(update);
}
void _onLivePhotoDownloadCallback(TaskStatusUpdate update) {
onLivePhotoDownloadStatus?.call(update);
}
Future<bool> saveImageWithPath(Task task) async {
final filePath = await task.filePath();
final title = task.filename;
final relativePath = Platform.isAndroid ? 'DCIM/Immich' : null;
try {
final Asset? resultAsset = await _fileMediaRepository.saveImageWithFile(
filePath,
title: title,
relativePath: relativePath,
);
return resultAsset != null;
} catch (error, stack) {
_log.severe("Error saving image", error, stack);
return false;
} finally {
if (await File(filePath).exists()) {
await File(filePath).delete();
}
}
}
Future<bool> saveVideo(Task task) async {
final filePath = await task.filePath();
final title = task.filename;
final relativePath = Platform.isAndroid ? 'DCIM/Immich' : null;
final file = File(filePath);
try {
final Asset? resultAsset = await _fileMediaRepository.saveVideo(
file,
title: title,
relativePath: relativePath,
);
return resultAsset != null;
} catch (error, stack) {
_log.severe("Error saving video", error, stack);
return false;
} finally {
if (await file.exists()) {
await file.delete();
}
}
}
Future<bool> saveLivePhotos(
Task task,
String livePhotosId,
) async {
final records = await _downloadRepository.getLiveVideoTasks();
if (records.length < 2) {
return false;
}
final imageRecord =
_findTaskRecord(records, livePhotosId, LivePhotosPart.image);
final videoRecord =
_findTaskRecord(records, livePhotosId, LivePhotosPart.video);
final imageFilePath = await imageRecord.task.filePath();
final videoFilePath = await videoRecord.task.filePath();
try {
final result = await _fileMediaRepository.saveLivePhoto(
image: File(imageFilePath),
video: File(videoFilePath),
title: task.filename,
);
return result != null;
} on PlatformException catch (error, stack) {
// Handle saving MotionPhotos on iOS
if (error.code == 'PHPhotosErrorDomain (-1)') {
final result = await _fileMediaRepository
.saveImageWithFile(imageFilePath, title: task.filename);
return result != null;
}
_log.severe("Error saving live photo", error, stack);
return false;
} catch (error, stack) {
_log.severe("Error saving live photo", error, stack);
return false;
} finally {
final imageFile = File(imageFilePath);
if (await imageFile.exists()) {
await imageFile.delete();
}
final videoFile = File(videoFilePath);
if (await videoFile.exists()) {
await videoFile.delete();
}
await _downloadRepository.deleteRecordsWithIds([
imageRecord.task.taskId,
videoRecord.task.taskId,
]);
}
}
Future<bool> cancelDownload(String id) async {
return await FileDownloader().cancelTaskWithId(id);
}
Future<List<bool>> downloadAll(List<Asset> assets) async {
return await _downloadRepository
.downloadAll(assets.expand(_createDownloadTasks).toList());
}
Future<void> download(Asset asset) async {
final tasks = _createDownloadTasks(asset);
await _downloadRepository.downloadAll(tasks);
}
List<DownloadTask> _createDownloadTasks(Asset asset) {
if (asset.isImage && asset.livePhotoVideoId != null && Platform.isIOS) {
return [
_buildDownloadTask(
asset.remoteId!,
asset.fileName,
group: downloadGroupLivePhoto,
metadata: LivePhotosMetadata(
part: LivePhotosPart.image,
id: asset.remoteId!,
).toJson(),
),
_buildDownloadTask(
asset.livePhotoVideoId!,
asset.fileName
.toUpperCase()
.replaceAll(RegExp(r"\.(JPG|HEIC)$"), '.MOV'),
group: downloadGroupLivePhoto,
metadata: LivePhotosMetadata(
part: LivePhotosPart.video,
id: asset.remoteId!,
).toJson(),
),
];
}
if (asset.remoteId == null) {
return [];
}
return [
_buildDownloadTask(
asset.remoteId!,
asset.fileName,
group: asset.isImage ? downloadGroupImage : downloadGroupVideo,
),
];
}
DownloadTask _buildDownloadTask(
String id,
String filename, {
String? group,
String? metadata,
}) {
final path = r'/assets/{id}/original'.replaceAll('{id}', id);
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final headers = ApiService.getRequestHeaders();
return DownloadTask(
taskId: id,
url: serverEndpoint + path,
headers: headers,
filename: filename,
updates: Updates.statusAndProgress,
group: group ?? '',
metaData: metadata ?? '',
);
}
}
TaskRecord _findTaskRecord(
List<TaskRecord> records,
String livePhotosId,
LivePhotosPart part,
) {
return records.firstWhere((record) {
final metadata = LivePhotosMetadata.fromJson(record.task.metaData);
return metadata.id == livePhotosId && metadata.part == part;
});
}