feat: manually upload local assets to album

This commit is contained in:
Alex
2026-04-27 19:58:04 -05:00
parent fe9e5afcf4
commit 2210730267
9 changed files with 286 additions and 34 deletions
@@ -9,6 +9,7 @@ import 'package:immich_mobile/infrastructure/repositories/remote_album.repositor
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
final localAlbumRepository = Provider<DriftLocalAlbumRepository>(
(ref) => DriftLocalAlbumRepository(ref.watch(driftProvider)),
@@ -33,7 +34,11 @@ final remoteAlbumRepository = Provider<DriftRemoteAlbumRepository>(
);
final remoteAlbumServiceProvider = Provider<RemoteAlbumService>(
(ref) => RemoteAlbumService(ref.watch(remoteAlbumRepository), ref.watch(driftAlbumApiRepositoryProvider)),
(ref) => RemoteAlbumService(
ref.watch(remoteAlbumRepository),
ref.watch(driftAlbumApiRepositoryProvider),
ref.watch(foregroundUploadServiceProvider),
),
dependencies: [remoteAlbumRepository],
);
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
@@ -6,8 +8,10 @@ import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:logging/logging.dart';
class RemoteAlbumState {
@@ -103,6 +107,42 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
}
}
/// Creates an album from a heterogeneous asset selection. Already-remote
/// assets seed the album immediately; local-only assets are uploaded and
/// linked one-by-one as each upload completes. Per-asset progress is
/// reported via [assetUploadProgressProvider].
Future<RemoteAlbum?> createAlbumWithAssets({
required String title,
String? description,
Iterable<BaseAsset> assets = const [],
}) async {
try {
final currentUser = ref.read(currentUserProvider);
if (currentUser == null) {
throw Exception('User not logged in');
}
final candidates = RemoteAlbumService.categorizeCandidates(assets);
final album = await _runWithProgressBridge(
candidates.localAssetsToUpload,
(callbacks) => _remoteAlbumService.createAlbumWithAssets(
title: title,
owner: currentUser,
description: description,
candidates: candidates,
uploadCallbacks: callbacks,
),
);
state = state.copyWith(albums: [...state.albums, album]);
return album;
} catch (error, stack) {
_logger.severe('Failed to create album with assets', error, stack);
rethrow;
}
}
Future<RemoteAlbum?> updateAlbum(
String albumId, {
String? name,
@@ -157,6 +197,73 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
return _remoteAlbumService.addAssets(albumId: albumId, assetIds: assetIds);
}
/// Adds a heterogeneous asset selection to an album. Already-remote assets
/// are linked immediately; local-only assets are uploaded and linked one-by-
/// one as each upload completes. Per-asset progress is reported via
/// [assetUploadProgressProvider].
Future<int> addAssetsToAlbum(String albumId, Iterable<BaseAsset> assets) async {
final currentUser = ref.read(currentUserProvider);
if (currentUser == null) {
throw Exception('User not logged in');
}
final candidates = RemoteAlbumService.categorizeCandidates(assets);
try {
return await _runWithProgressBridge(
candidates.localAssetsToUpload,
(callbacks) => _remoteAlbumService.addAssetsToAlbum(
albumId: albumId,
uploader: currentUser,
candidates: candidates,
uploadCallbacks: callbacks,
),
);
} catch (error, stack) {
_logger.severe('Failed to add assets to album $albumId', error, stack);
rethrow;
}
}
/// Bridges [UploadCallbacks] from the service to [assetUploadProgressProvider]
/// and the manual upload cancel token, so the UI's existing progress overlays
/// pick up the work without each caller wiring it manually.
Future<T> _runWithProgressBridge<T>(
List<LocalAsset> localAssets,
Future<T> Function(UploadCallbacks callbacks) action,
) async {
if (localAssets.isEmpty) {
return action(const UploadCallbacks());
}
final progressNotifier = ref.read(assetUploadProgressProvider.notifier);
final cancelToken = Completer<void>();
ref.read(manualUploadCancelTokenProvider.notifier).state = cancelToken;
for (final asset in localAssets) {
progressNotifier.setProgress(asset.id, 0.0);
}
try {
return await action(
UploadCallbacks(
onProgress: (localAssetId, _, bytes, totalBytes) {
final progress = totalBytes > 0 ? bytes / totalBytes : 0.0;
progressNotifier.setProgress(localAssetId, progress);
},
onSuccess: (localAssetId, _) {
progressNotifier.remove(localAssetId);
},
onError: (localAssetId, _) {
progressNotifier.setError(localAssetId);
},
),
);
} finally {
ref.read(manualUploadCancelTokenProvider.notifier).state = null;
Future.delayed(const Duration(seconds: 2), progressNotifier.clear);
}
}
Future<void> addUsers(String albumId, List<String> userIds) {
return _remoteAlbumService.addUsers(albumId: albumId, userIds: userIds);
}