Compare commits

..

1 Commits

Author SHA1 Message Date
renovate[bot] e4f1693f96 chore(deps): update machine-learning 2026-05-28 06:09:46 +00:00
11 changed files with 817 additions and 623 deletions
+4 -4
View File
@@ -1,8 +1,8 @@
ARG DEVICE=cpu
FROM python:3.11-bookworm@sha256:970c99f886b839fc8829289040c1845dadaf2cae46b37acc7710333158ec29b4 AS builder-cpu
FROM python:3.11-bookworm@sha256:121d86b6d08752968a7dddbc708849e5f3a839bbff47f32212b46d2a1d842bab AS builder-cpu
FROM python:3.13-slim-trixie@sha256:d168b8d9eb761f4d3fe305ebd04aeb7e7f2de0297cec5fb2f8f6403244621664 AS builder-openvino
FROM python:3.13-slim-trixie@sha256:b04b5d7233d2ad9c379e22ea8927cd1378cd15c60d4ef876c065b25ea8fb3bf3 AS builder-openvino
FROM builder-cpu AS builder-cuda
@@ -39,12 +39,12 @@ RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --frozen --extra ${DEVICE} --no-dev --no-editable --no-install-project --compile-bytecode --no-progress --active --link-mode copy
FROM python:3.11-slim-bookworm@sha256:9c6f90801e6b68e772b7c0ca74260cbf7af9f320acec894e26fccdaccfbe3b47 AS prod-cpu
FROM python:3.11-slim-bookworm@sha256:8dca233de9f3d9bb410665f00a4da6dd06f331083137e0e98ccf227236fcc438 AS prod-cpu
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \
MACHINE_LEARNING_MODEL_ARENA=false
FROM python:3.13-slim-trixie@sha256:d168b8d9eb761f4d3fe305ebd04aeb7e7f2de0297cec5fb2f8f6403244621664 AS prod-openvino
FROM python:3.13-slim-trixie@sha256:b04b5d7233d2ad9c379e22ea8927cd1378cd15c60d4ef876c065b25ea8fb3bf3 AS prod-openvino
RUN apt-get update && \
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
+495 -447
View File
File diff suppressed because it is too large Load Diff
@@ -234,13 +234,24 @@ class RemoteAlbumService {
final pendingAdds = <Future<void>>[];
final localById = {for (final a in localAssets) a.id: a};
final UploadCallbacks(:onProgress, :onSuccess, :onError, :onICloudProgress) = userCallbacks;
final wrappedCallbacks = UploadCallbacks(
onProgress: onProgress,
onICloudProgress: onICloudProgress,
onError: onError,
onProgress: (localId, filename, bytes, totalBytes) => _runUploadCallback(
'Upload progress callback failed for $localId',
() => userCallbacks.onProgress?.call(localId, filename, bytes, totalBytes),
),
onICloudProgress: (localId, progress) => _runUploadCallback(
'iCloud progress callback failed for $localId',
() => userCallbacks.onICloudProgress?.call(localId, progress),
),
onError: (localId, errorMessage) => _runUploadCallback(
'Upload error callback failed for $localId',
() => userCallbacks.onError?.call(localId, errorMessage),
),
onSuccess: (localId, remoteId) {
onSuccess?.call(localId, remoteId);
_runUploadCallback(
'Upload success callback failed for $localId',
() => userCallbacks.onSuccess?.call(localId, remoteId),
);
final source = localById[localId];
if (source == null) {
_logger.warning('Upload success for $localId but source LocalAsset missing; skipping album link');
@@ -248,22 +259,29 @@ class RemoteAlbumService {
}
pendingAdds.add(
_linkUploadedAssetToAlbum(albumId, remoteId, uploader, source)
.then<void>((added) => addedCount += added)
.onError(
(error, stack) =>
_logger.warning('Failed to add uploaded asset $remoteId to album $albumId', error, stack),
),
.then<void>((added) {
addedCount += added;
})
.catchError((Object error, StackTrace stack) {
_logger.warning('Failed to add uploaded asset $remoteId to album $albumId', error, stack);
}),
);
},
);
await _uploadService.uploadManual(localAssets, cancelToken: null, callbacks: wrappedCallbacks);
await _uploadService.uploadManual(localAssets, callbacks: wrappedCallbacks);
await Future.wait(pendingAdds);
return addedCount;
}
// TODO: this is a poorly designed flow; adding a "stub" just to satisfy FK constraints is hacky,
// it goes out of its way to insert one at a time, and it swallows errors that should be surfaced to the user.
void _runUploadCallback(String message, void Function() callback) {
try {
callback();
} catch (error, stack) {
_logger.warning(message, error, stack);
}
}
/// Links a freshly-uploaded asset to an album, ensuring the local DB
/// reflects the change without waiting for the next sync. We call the API
/// (server is the source of truth), then upsert a placeholder
@@ -1,4 +1,3 @@
import 'dart:async';
import 'dart:io';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
@@ -6,54 +5,66 @@ import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:logging/logging.dart';
import 'package:photo_manager/photo_manager.dart';
typedef OnProgress = void Function(String id, double progress);
class StorageRepository {
static final log = Logger('StorageRepository');
final log = Logger('StorageRepository');
const StorageRepository();
StorageRepository();
Future<File?> getAssetFile(String assetId, {OnProgress? onProgress, Completer<void>? cancelToken}) {
return _getFileForAsset(assetId, isMotion: false, onProgress: onProgress, cancelToken: cancelToken);
}
Future<File?> getMotionFile(String assetId, {OnProgress? onProgress, Completer<void>? cancelToken}) {
return _getFileForAsset(assetId, isMotion: true, onProgress: onProgress, cancelToken: cancelToken);
}
Future<File?> _getFileForAsset(
String assetId, {
bool isMotion = false,
OnProgress? onProgress,
Completer<void>? cancelToken,
}) async {
final entity = await AssetEntity.fromId(assetId);
if (entity == null) {
log.warning("Cannot get AssetEntity for asset $assetId");
return null;
}
PMProgressHandler? progressHandler;
StreamSubscription<PMProgressState>? progressSubscription;
PMCancelToken? pmCancelToken;
if (cancelToken != null) {
progressHandler = PMProgressHandler();
progressSubscription = progressHandler.stream.listen((event) => onProgress?.call(assetId, event.progress));
pmCancelToken = PMCancelToken();
unawaited(cancelToken.future.then((_) => pmCancelToken!.cancelRequest()));
}
Future<File?> getFileForAsset(String assetId) async {
File? file;
final log = Logger('StorageRepository');
try {
return await entity.loadFile(withSubtype: isMotion, progressHandler: progressHandler, cancelToken: pmCancelToken);
final entity = await AssetEntity.fromId(assetId);
file = await entity?.originFile;
if (file == null) {
log.warning("Cannot get file for asset $assetId");
return null;
}
final exists = await file.exists();
if (!exists) {
log.warning("File for asset $assetId does not exist");
return null;
}
} catch (error, stackTrace) {
log.warning("Error loading file for asset $assetId", error, stackTrace);
return null;
} finally {
unawaited(progressSubscription?.cancel());
log.warning("Error getting file for asset $assetId", error, stackTrace);
}
return file;
}
Future<File?> getMotionFileForAsset(LocalAsset asset) async {
File? file;
final log = Logger('StorageRepository');
try {
final entity = await AssetEntity.fromId(asset.id);
file = await entity?.originFileWithSubtype;
if (file == null) {
log.warning(
"Cannot get motion file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
);
return null;
}
final exists = await file.exists();
if (!exists) {
log.warning("Motion file for asset ${asset.id} does not exist");
return null;
}
} catch (error, stackTrace) {
log.warning(
"Error getting motion file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
error,
stackTrace,
);
}
return file;
}
Future<AssetEntity?> getAssetEntityForAsset(LocalAsset asset) async {
final log = Logger('StorageRepository');
AssetEntity? entity;
try {
@@ -88,7 +99,39 @@ class StorageRepository {
}
}
Future<File?> loadFileFromCloud(String assetId, {PMProgressHandler? progressHandler}) async {
try {
final entity = await AssetEntity.fromId(assetId);
if (entity == null) {
log.warning("Cannot get AssetEntity for asset $assetId");
return null;
}
return await entity.loadFile(progressHandler: progressHandler);
} catch (error, stackTrace) {
log.warning("Error loading file from cloud for asset $assetId", error, stackTrace);
return null;
}
}
Future<File?> loadMotionFileFromCloud(String assetId, {PMProgressHandler? progressHandler}) async {
try {
final entity = await AssetEntity.fromId(assetId);
if (entity == null) {
log.warning("Cannot get AssetEntity for asset $assetId");
return null;
}
return await entity.loadFile(withSubtype: true, progressHandler: progressHandler);
} catch (error, stackTrace) {
log.warning("Error loading motion file from cloud for asset $assetId", error, stackTrace);
return null;
}
}
Future<void> clearCache() async {
final log = Logger('StorageRepository');
try {
await PhotoManager.clearFileCache();
} catch (error, stackTrace) {
@@ -6,13 +6,13 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:logging/logging.dart';
import 'package:native_video_player/native_video_player.dart';
@@ -108,7 +108,7 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
try {
if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
final file = await ref.read(storageRepositoryProvider).getAssetFile(id);
final file = await StorageRepository().getFileForAsset(id);
if (!mounted) {
return null;
}
@@ -2,7 +2,6 @@ import 'dart:async';
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:logging/logging.dart';
import 'package:immich_mobile/constants/constants.dart';
@@ -274,7 +273,7 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
onProgress: _handleForegroundBackupProgress,
onSuccess: _handleForegroundBackupSuccess,
onError: _handleForegroundBackupError,
onICloudProgress: CurrentPlatform.isIOS ? _handleICloudProgress : null,
onICloudProgress: _handleICloudProgress,
),
);
}
@@ -283,7 +282,7 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
_cancelToken?.complete();
_cancelToken = null;
_uploadSpeedManager.clear();
state = state.copyWith(uploadItems: const {}, iCloudDownloadProgress: const {});
state = state.copyWith(uploadItems: {}, iCloudDownloadProgress: {});
}
void _handleICloudProgress(String localAssetId, double progress) {
@@ -1,4 +1,4 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
final storageRepositoryProvider = Provider<StorageRepository>((ref) => const StorageRepository());
final storageRepositoryProvider = Provider<StorageRepository>((ref) => StorageRepository());
+91 -43
View File
@@ -10,6 +10,7 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:logging/logging.dart';
import 'package:http/http.dart';
import 'package:immich_mobile/utils/debug_print.dart';
final uploadRepositoryProvider = Provider((ref) => UploadRepository());
@@ -19,14 +20,21 @@ class UploadRepository {
void Function(TaskProgressUpdate)? onTaskProgress;
UploadRepository() {
final downloader = FileDownloader();
for (final group in const [kBackupGroup, kBackupLivePhotoGroup, kManualUploadGroup]) {
downloader.registerCallbacks(
group: group,
taskStatusCallback: onUploadStatus,
taskProgressCallback: onTaskProgress,
);
}
FileDownloader().registerCallbacks(
group: kBackupGroup,
taskStatusCallback: (update) => onUploadStatus?.call(update),
taskProgressCallback: (update) => onTaskProgress?.call(update),
);
FileDownloader().registerCallbacks(
group: kBackupLivePhotoGroup,
taskStatusCallback: (update) => onUploadStatus?.call(update),
taskProgressCallback: (update) => onTaskProgress?.call(update),
);
FileDownloader().registerCallbacks(
group: kManualUploadGroup,
taskStatusCallback: (update) => onUploadStatus?.call(update),
taskProgressCallback: (update) => onTaskProgress?.call(update),
);
}
Future<void> enqueueBackground(UploadTask task) {
@@ -58,6 +66,28 @@ class UploadRepository {
return FileDownloader().start();
}
Future<void> getUploadInfo() async {
final [enqueuedTasks, runningTasks, canceledTasks, waitingTasks, pausedTasks] = await Future.wait([
FileDownloader().database.allRecordsWithStatus(TaskStatus.enqueued, group: kBackupGroup),
FileDownloader().database.allRecordsWithStatus(TaskStatus.running, group: kBackupGroup),
FileDownloader().database.allRecordsWithStatus(TaskStatus.canceled, group: kBackupGroup),
FileDownloader().database.allRecordsWithStatus(TaskStatus.waitingToRetry, group: kBackupGroup),
FileDownloader().database.allRecordsWithStatus(TaskStatus.paused, group: kBackupGroup),
]);
dPrint(
() =>
"""
Upload Info:
Enqueued: ${enqueuedTasks.length}
Running: ${runningTasks.length}
Canceled: ${canceledTasks.length}
Waiting: ${waitingTasks.length}
Paused: ${pausedTasks.length}
""",
);
}
Future<UploadResult> uploadFile({
required File file,
required String originalFileName,
@@ -81,30 +111,41 @@ class UploadRepository {
baseRequest.fields.addAll(fields);
baseRequest.files.add(assetRawUploadData);
final StreamedResponse(:statusCode, :stream) = await NetworkRepository.client.send(baseRequest);
final responseBodyString = await stream.bytesToString();
final response = await NetworkRepository.client.send(baseRequest);
final responseBodyString = await response.stream.bytesToString();
return switch ((statusCode, _tryJsonDecode(responseBodyString))) {
(200 || 201, {'id': String id}) => UploadSuccess(remoteAssetId: id),
(413, _) => const UploadError(statusCode: 413, message: 'File is too large to upload'),
(_, {'message': String message}) => UploadError(statusCode: statusCode, message: message),
_ => UploadError(statusCode: statusCode, message: 'Upload failed with status $statusCode'),
};
if (![200, 201].contains(response.statusCode)) {
String? errorMessage;
if (response.statusCode == 413) {
errorMessage = 'Error(413) File is too large to upload';
return UploadResult.error(statusCode: response.statusCode, errorMessage: errorMessage);
}
try {
final error = jsonDecode(responseBodyString);
errorMessage = error['message'] ?? error['error'];
} catch (_) {
errorMessage = responseBodyString.isNotEmpty
? responseBodyString
: 'Upload failed with status ${response.statusCode}';
}
return UploadResult.error(statusCode: response.statusCode, errorMessage: errorMessage);
}
try {
final responseBody = jsonDecode(responseBodyString);
return UploadResult.success(remoteAssetId: responseBody['id'] as String);
} catch (e) {
return UploadResult.error(errorMessage: 'Failed to parse server response');
}
} on RequestAbortedException {
logger.warning("Upload $logContext was cancelled");
return const UploadCancelled();
return UploadResult.cancelled();
} catch (error, stackTrace) {
logger.warning("Error uploading $logContext: ${error.toString()}: $stackTrace");
return UploadError(message: error.toString());
}
}
@pragma('vm:prefer-inline')
Map? _tryJsonDecode(String s) {
try {
return (jsonDecode(s) as Map);
} catch (_) {
return null;
return UploadResult.error(errorMessage: error.toString());
}
}
}
@@ -139,23 +180,30 @@ class ProgressMultipartRequest extends MultipartRequest with Abortable {
}
}
sealed class UploadResult {
const UploadResult();
}
final class UploadSuccess extends UploadResult {
final String remoteAssetId;
const UploadSuccess({required this.remoteAssetId});
}
final class UploadError extends UploadResult {
final String message;
class UploadResult {
final bool isSuccess;
final bool isCancelled;
final String? remoteAssetId;
final String? errorMessage;
final int? statusCode;
const UploadError({required this.message, this.statusCode});
}
const UploadResult({
required this.isSuccess,
required this.isCancelled,
this.remoteAssetId,
this.errorMessage,
this.statusCode,
});
final class UploadCancelled extends UploadResult {
const UploadCancelled();
factory UploadResult.success({required String remoteAssetId}) {
return UploadResult(isSuccess: true, isCancelled: false, remoteAssetId: remoteAssetId);
}
factory UploadResult.error({String? errorMessage, int? statusCode}) {
return UploadResult(isSuccess: false, isCancelled: false, errorMessage: errorMessage, statusCode: statusCode);
}
factory UploadResult.cancelled() {
return const UploadResult(isSuccess: false, isCancelled: true);
}
}
@@ -266,6 +266,8 @@ class BackgroundUploadService {
return null;
}
File? file;
/// iOS LivePhoto has two files: a photo and a video.
/// They are uploaded separately, with video file being upload first, then returned with the assetId
/// The assetId is then used as a metadata for the photo file upload task.
@@ -276,9 +278,11 @@ class BackgroundUploadService {
/// The cancel operation will only cancel the video group (normal group), the photo group will not
/// be touched, as the video file is already uploaded.
final file = await (entity.isLivePhoto
? _storageRepository.getMotionFile(asset.id)
: _storageRepository.getAssetFile(asset.id));
if (entity.isLivePhoto) {
file = await _storageRepository.getMotionFileForAsset(asset);
} else {
file = await _storageRepository.getFileForAsset(asset.id);
}
if (file == null) {
_logger.warning("Failed to get file for asset ${asset.id} - ${asset.name}");
@@ -326,7 +330,7 @@ class BackgroundUploadService {
return null;
}
final file = await _storageRepository.getAssetFile(asset.id);
final file = await _storageRepository.getFileForAsset(asset.id);
if (file == null) {
return null;
}
@@ -20,6 +20,7 @@ import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/upload.repository.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as p;
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
/// Callbacks for upload progress and status updates
class UploadCallbacks {
@@ -98,7 +99,7 @@ class ForegroundUploadService {
final requireWifi = _shouldRequireWiFi(asset);
return requireWifi && !hasWifi;
},
processItem: (asset) => _uploadSingleAsset(asset, cancelToken: cancelToken, callbacks: callbacks),
processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks),
);
}
}
@@ -124,14 +125,14 @@ class ForegroundUploadService {
continue;
}
await _uploadSingleAsset(asset, cancelToken: cancelToken, callbacks: callbacks);
await _uploadSingleAsset(asset, cancelToken, callbacks: callbacks);
}
}
/// Manually upload picked local assets
Future<void> uploadManual(
List<LocalAsset> localAssets, {
required Completer<void>? cancelToken,
Completer<void>? cancelToken,
UploadCallbacks callbacks = const UploadCallbacks(),
}) async {
if (localAssets.isEmpty) {
@@ -141,7 +142,7 @@ class ForegroundUploadService {
await _executeWithWorkerPool<LocalAsset>(
items: localAssets,
cancelToken: cancelToken,
processItem: (asset) => _uploadSingleAsset(asset, cancelToken: cancelToken, callbacks: callbacks),
processItem: (asset) => _uploadSingleAsset(asset, cancelToken, callbacks: callbacks),
);
}
@@ -169,11 +170,11 @@ class ForegroundUploadService {
onProgress: (bytes, totalBytes) => onProgress?.call(fileId, bytes, totalBytes),
);
return switch (result) {
UploadSuccess() => onSuccess?.call(fileId),
UploadError(:final message) => onError?.call(fileId, message),
UploadCancelled() => null,
};
if (result.isSuccess) {
onSuccess?.call(fileId);
} else if (!result.isCancelled && result.errorMessage != null) {
onError?.call(fileId, result.errorMessage!);
}
},
);
}
@@ -215,7 +216,7 @@ class ForegroundUploadService {
final item = items[index];
if (shouldSkip != null && shouldSkip(item)) {
if (shouldSkip?.call(item) ?? false) {
continue;
}
@@ -232,48 +233,78 @@ class ForegroundUploadService {
}
Future<void> _uploadSingleAsset(
LocalAsset asset, {
required Completer<void>? cancelToken,
LocalAsset asset,
Completer<void>? cancelToken, {
required UploadCallbacks callbacks,
}) async {
final UploadCallbacks(:onProgress, :onSuccess, :onError, :onICloudProgress) = callbacks;
File? assetFile;
File? file;
File? livePhotoFile;
try {
final entity = await _storageRepository.getAssetEntityForAsset(asset);
if (entity == null) {
onError?.call(
callbacks.onError?.call(
asset.localId!,
CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(),
);
return;
}
File? file;
if (entity.isLivePhoto) {
file = await _storageRepository.getMotionFile(asset.id, cancelToken: cancelToken, onProgress: onICloudProgress);
final isAvailableLocally = await _storageRepository.isAssetAvailableLocally(asset.id);
if (!isAvailableLocally && CurrentPlatform.isIOS) {
_logger.info("Loading iCloud asset ${asset.id} - ${asset.name}");
// Create progress handler for iCloud download
PMProgressHandler? progressHandler;
StreamSubscription? progressSubscription;
progressHandler = PMProgressHandler();
progressSubscription = progressHandler.stream.listen((event) {
callbacks.onICloudProgress?.call(asset.localId!, event.progress);
});
try {
file = await _storageRepository.loadFileFromCloud(asset.id, progressHandler: progressHandler);
if (entity.isLivePhoto) {
livePhotoFile = await _storageRepository.loadMotionFileFromCloud(
asset.id,
progressHandler: progressHandler,
);
}
} finally {
await progressSubscription.cancel();
}
} else {
// Get files locally
file = await _storageRepository.getFileForAsset(asset.id);
if (file == null) {
_logger.warning("Failed to obtain motion part of the livePhoto - ${asset.name}");
onError?.call(
_logger.warning("Failed to get file ${asset.id} - ${asset.name}");
callbacks.onError?.call(
asset.localId!,
CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(),
);
return;
}
livePhotoFile = file;
// For live photos, get the motion video file
if (entity.isLivePhoto) {
livePhotoFile = await _storageRepository.getMotionFileForAsset(asset);
if (livePhotoFile == null) {
_logger.warning("Failed to obtain motion part of the livePhoto - ${asset.name}");
callbacks.onError?.call(
asset.localId!,
CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(),
);
}
}
}
file = await _storageRepository.getAssetFile(asset.id, cancelToken: cancelToken, onProgress: onICloudProgress);
if (file == null) {
_logger.warning("Failed to get file ${asset.id} - ${asset.name}");
onError?.call(
asset.localId!,
CurrentPlatform.isAndroid ? "asset_not_found_on_device_android".t() : "asset_not_found_on_device_ios".t(),
);
_logger.warning("Failed to obtain file from iCloud for asset ${asset.id} - ${asset.name}");
callbacks.onError?.call(asset.localId!, "asset_not_found_on_icloud".t());
return;
}
assetFile = file;
String fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name;
@@ -299,9 +330,11 @@ class ForegroundUploadService {
};
// Upload live photo video first if available
String? livePhotoVideoId;
if (entity.isLivePhoto && livePhotoFile != null) {
final livePhotoTitle = p.setExtension(originalFileName, p.extension(livePhotoFile.path));
final onProgress = callbacks.onProgress;
final livePhotoResult = await _uploadRepository.uploadFile(
file: livePhotoFile,
originalFileName: livePhotoTitle,
@@ -313,16 +346,15 @@ class ForegroundUploadService {
logContext: 'livePhotoVideo[${asset.localId}]',
);
switch (livePhotoResult) {
case UploadSuccess(:final remoteAssetId):
fields['livePhotoVideoId'] = remoteAssetId;
case UploadError(:final message):
onError?.call(asset.localId!, "Failed to upload live photo video: $message");
return;
case UploadCancelled():
if (livePhotoResult.isSuccess && livePhotoResult.remoteAssetId != null) {
livePhotoVideoId = livePhotoResult.remoteAssetId;
}
}
if (livePhotoVideoId != null) {
fields['livePhotoVideoId'] = livePhotoVideoId;
}
// Add cloudId metadata only to the still image, not the motion video, becasue when the sync id happens, the motion video can get associated with the wrong still image.
if (CurrentPlatform.isIOS && asset.cloudId != null) {
fields['metadata'] = jsonEncode([
@@ -339,6 +371,7 @@ class ForegroundUploadService {
]);
}
final onProgress = callbacks.onProgress;
final result = await _uploadRepository.uploadFile(
file: file,
originalFileName: originalFileName,
@@ -350,33 +383,34 @@ class ForegroundUploadService {
logContext: 'asset[${asset.localId}]',
);
switch (result) {
case UploadSuccess(:final remoteAssetId):
onSuccess?.call(asset.localId!, remoteAssetId);
case UploadCancelled():
if (result.isSuccess && result.remoteAssetId != null) {
callbacks.onSuccess?.call(asset.localId!, result.remoteAssetId!);
} else if (result.isCancelled) {
_logger.warning(() => "Backup was cancelled by the user");
shouldAbortUpload = true;
} else if (result.errorMessage != null) {
_logger.severe(
() =>
"Error(${result.statusCode}) uploading ${asset.localId} | $originalFileName | Created on ${asset.createdAt} | ${result.errorMessage}",
);
callbacks.onError?.call(asset.localId!, result.errorMessage!);
if (result.errorMessage == "Quota has been exceeded!") {
shouldAbortUpload = true;
_logger.warning("Upload was cancelled by the user for asset ${asset.localId}");
case UploadError(:final message, :final statusCode):
shouldAbortUpload |= message == "Quota has been exceeded!";
_logger.severe(
"Error(${statusCode ?? 'unknown'}) uploading ${asset.localId} | $originalFileName | Created on ${asset.createdAt} | $message",
);
onError?.call(asset.localId!, message);
}
}
} catch (error, stackTrace) {
_logger.severe("Asset backup failed", error, stackTrace);
onError?.call(asset.localId!, error.toString());
_logger.severe(() => "Error backup asset: ${error.toString()}", stackTrace);
callbacks.onError?.call(asset.localId!, error.toString());
} finally {
if (Platform.isIOS) {
unawaited(
Future.wait([
if (assetFile != null) assetFile.delete(),
if (livePhotoFile != null) livePhotoFile.delete(),
]).onError((error, stackTrace) {
_logger.severe("Post-upload file cleanup failed", error, stackTrace);
return const [];
}),
);
try {
await file?.delete();
await livePhotoFile?.delete();
} catch (error, stackTrace) {
_logger.severe(() => "ERROR deleting file: ${error.toString()}", stackTrace);
}
}
}
}
@@ -412,7 +446,7 @@ class ForegroundUploadService {
logContext: 'shareIntent[$deviceAssetId]',
);
} catch (e) {
return UploadError(message: e.toString());
return UploadResult.error(errorMessage: e.toString());
}
}
@@ -75,7 +75,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getAssetFile(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'OriginalPhoto.jpg');
final task = await sut.getUploadTask(asset);
@@ -92,7 +92,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getAssetFile(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => null);
final task = await sut.getUploadTask(asset);
@@ -109,7 +109,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(true);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getMotionFile(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockStorageRepository.getMotionFileForAsset(asset)).thenAnswer((_) async => mockFile);
when(
() => mockAssetMediaRepository.getOriginalFilename(asset.id),
).thenAnswer((_) async => 'OriginalLivePhoto.HEIC');
@@ -130,7 +130,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(true);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getAssetFile(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
when(
() => mockAssetMediaRepository.getOriginalFilename(asset.id),
).thenAnswer((_) async => 'OriginalLivePhoto.HEIC');
@@ -150,7 +150,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(true);
when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getAssetFile(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => null);
final task = await sut.getLivePhotoUploadTask(asset, 'video-id-456');
@@ -194,7 +194,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getAssetFile(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id)).thenAnswer((_) async => 'test.jpg');
final task = await sutWithV24.getUploadTask(assetWithCloudId);
@@ -243,7 +243,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getAssetFile(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
when(() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id)).thenAnswer((_) async => 'test.jpg');
final task = await sutAndroid.getUploadTask(assetWithCloudId);
@@ -281,7 +281,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(false);
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithoutCloudId)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getAssetFile(assetWithoutCloudId.id)).thenAnswer((_) async => mockFile);
when(() => mockStorageRepository.getFileForAsset(assetWithoutCloudId.id)).thenAnswer((_) async => mockFile);
when(
() => mockAssetMediaRepository.getOriginalFilename(assetWithoutCloudId.id),
).thenAnswer((_) async => 'test.jpg');
@@ -323,7 +323,7 @@ void main() {
when(() => mockEntity.isLivePhoto).thenReturn(true);
when(() => mockStorageRepository.getAssetEntityForAsset(assetWithCloudId)).thenAnswer((_) async => mockEntity);
when(() => mockStorageRepository.getAssetFile(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
when(() => mockStorageRepository.getFileForAsset(assetWithCloudId.id)).thenAnswer((_) async => mockFile);
when(
() => mockAssetMediaRepository.getOriginalFilename(assetWithCloudId.id),
).thenAnswer((_) async => 'livephoto.heic');