From 2210730267317128c92b4f0be3e3d1a6b3ff2f05 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 27 Apr 2026 19:58:04 -0500 Subject: [PATCH] feat: manually upload local assets to album --- .../models/asset/remote_asset.model.dart | 9 +- .../domain/services/remote_album.service.dart | 130 +++++++++++++++++- .../repositories/remote_album.repository.dart | 32 +++++ .../presentation/pages/drift_album.page.dart | 1 + .../drift_asset_selection_timeline.page.dart | 11 +- .../pages/drift_create_album.page.dart | 11 +- .../pages/drift_remote_album.page.dart | 12 +- .../infrastructure/album.provider.dart | 7 +- .../infrastructure/remote_album.provider.dart | 107 ++++++++++++++ 9 files changed, 286 insertions(+), 34 deletions(-) diff --git a/mobile/lib/domain/models/asset/remote_asset.model.dart b/mobile/lib/domain/models/asset/remote_asset.model.dart index 36dc6242e1..22648dac0f 100644 --- a/mobile/lib/domain/models/asset/remote_asset.model.dart +++ b/mobile/lib/domain/models/asset/remote_asset.model.dart @@ -81,15 +81,10 @@ class RemoteAsset extends BaseAsset { stackId == other.stackId; } + // Mirrors `==` above, which deliberately does not compare localId. @override int get hashCode => - super.hashCode ^ - id.hashCode ^ - ownerId.hashCode ^ - localId.hashCode ^ - thumbHash.hashCode ^ - visibility.hashCode ^ - stackId.hashCode; + super.hashCode ^ id.hashCode ^ ownerId.hashCode ^ thumbHash.hashCode ^ visibility.hashCode ^ stackId.hashCode; RemoteAsset copyWith({ String? id, diff --git a/mobile/lib/domain/services/remote_album.service.dart b/mobile/lib/domain/services/remote_album.service.dart index f060ba9290..cb5f6489ea 100644 --- a/mobile/lib/domain/services/remote_album.service.dart +++ b/mobile/lib/domain/services/remote_album.service.dart @@ -9,12 +9,47 @@ import 'package:immich_mobile/infrastructure/repositories/remote_album.repositor 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/repositories/drift_album_api_repository.dart'; +import 'package:immich_mobile/services/foreground_upload.service.dart'; +import 'package:logging/logging.dart'; + +/// Categorizes a heterogeneous asset selection into the candidates that can +/// be added to an album immediately (already on the server) and the local-only +/// candidates that must be uploaded first. +class AlbumAssetCandidates { + final List remoteAssetIds; + final List localAssetsToUpload; + + const AlbumAssetCandidates({required this.remoteAssetIds, required this.localAssetsToUpload}); +} class RemoteAlbumService { + static final _logger = Logger('RemoteAlbumService'); + final DriftRemoteAlbumRepository _repository; final DriftAlbumApiRepository _albumApiRepository; + final ForegroundUploadService _uploadService; - const RemoteAlbumService(this._repository, this._albumApiRepository); + const RemoteAlbumService(this._repository, this._albumApiRepository, this._uploadService); + + /// Categorizes a heterogeneous asset selection into already-on-server IDs + /// and local assets that still need to be uploaded. + static AlbumAssetCandidates categorizeCandidates(Iterable assets) { + final remoteIds = []; + final localToUpload = []; + for (final asset in assets) { + if (asset is RemoteAsset) { + remoteIds.add(asset.id); + } else if (asset is LocalAsset) { + final remoteId = asset.remoteId; + if (remoteId != null) { + remoteIds.add(remoteId); + } else { + localToUpload.add(asset); + } + } + } + return AlbumAssetCandidates(remoteAssetIds: remoteIds, localAssetsToUpload: localToUpload); + } Stream watchAlbum(String albumId) { return _repository.watchAlbum(albumId); @@ -148,6 +183,99 @@ class RemoteAlbumService { return album.added.length; } + /// Adds an asset selection to an album, uploading any local-only assets + /// first and linking each one as soon as its upload completes. + Future addAssetsToAlbum({ + required String albumId, + required UserDto uploader, + required AlbumAssetCandidates candidates, + UploadCallbacks uploadCallbacks = const UploadCallbacks(), + }) async { + int addedCount = 0; + if (candidates.remoteAssetIds.isNotEmpty) { + addedCount += await addAssets(albumId: albumId, assetIds: candidates.remoteAssetIds); + } + if (candidates.localAssetsToUpload.isNotEmpty) { + addedCount += await _uploadAndAddLocals(albumId, uploader, candidates.localAssetsToUpload, uploadCallbacks); + } + return addedCount; + } + + /// Creates an album, seeding it with already-remote asset IDs, then uploads + /// local-only assets and links each one as it finishes. + Future createAlbumWithAssets({ + required String title, + required UserDto owner, + String? description, + AlbumAssetCandidates candidates = const AlbumAssetCandidates(remoteAssetIds: [], localAssetsToUpload: []), + UploadCallbacks uploadCallbacks = const UploadCallbacks(), + }) async { + final album = await createAlbum( + title: title, + owner: owner, + description: description, + assetIds: candidates.remoteAssetIds, + ); + if (candidates.localAssetsToUpload.isNotEmpty) { + await _uploadAndAddLocals(album.id, owner, candidates.localAssetsToUpload, uploadCallbacks); + } + return album; + } + + Future _uploadAndAddLocals( + String albumId, + UserDto uploader, + List localAssets, + UploadCallbacks userCallbacks, + ) async { + int addedCount = 0; + final pendingAdds = >[]; + final localById = {for (final a in localAssets) a.id: a}; + + final wrappedCallbacks = UploadCallbacks( + onProgress: userCallbacks.onProgress, + onICloudProgress: userCallbacks.onICloudProgress, + onError: userCallbacks.onError, + onSuccess: (localId, remoteId) { + 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'); + return; + } + pendingAdds.add( + _linkUploadedAssetToAlbum(albumId, remoteId, uploader, source) + .then((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, callbacks: wrappedCallbacks); + await Future.wait(pendingAdds); + return addedCount; + } + + /// 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 + /// `remote_asset_entity` row from the local source so the FK-protected + /// junction insert succeeds. Sync overwrites the placeholder later with + /// the authoritative server data. + Future _linkUploadedAssetToAlbum(String albumId, String remoteId, UserDto uploader, LocalAsset source) async { + final result = await _albumApiRepository.addAssets(albumId, [remoteId]); + if (result.added.isEmpty) { + return 0; + } + await _repository.upsertRemoteAssetStub(remoteId: remoteId, ownerId: uploader.id, source: source); + await _repository.addAssets(albumId, result.added); + return result.added.length; + } + Future deleteAlbum(String albumId) async { await _albumApiRepository.deleteAlbum(albumId); diff --git a/mobile/lib/infrastructure/repositories/remote_album.repository.dart b/mobile/lib/infrastructure/repositories/remote_album.repository.dart index 19ebcaac45..545719f892 100644 --- a/mobile/lib/infrastructure/repositories/remote_album.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_album.repository.dart @@ -10,6 +10,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift. import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; enum SortRemoteAlbumsBy { id, updatedAt } @@ -285,6 +286,37 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository { return assetIds.length; } + /// Inserts a placeholder `remote_asset_entity` row from a freshly-uploaded + /// local asset. Skips silently if a row with the same id or + /// (owner_id, checksum) already exists — sync will overwrite with the + /// authoritative server data once the AssetUploadReadyV1 event is processed. + Future upsertRemoteAssetStub({ + required String remoteId, + required String ownerId, + required LocalAsset source, + }) async { + await _db + .into(_db.remoteAssetEntity) + .insert( + RemoteAssetEntityCompanion( + id: Value(remoteId), + ownerId: Value(ownerId), + checksum: Value(source.checksum ?? ''), + name: Value(source.name), + type: Value(source.type), + createdAt: Value(source.createdAt), + updatedAt: Value(source.updatedAt), + width: Value(source.width), + height: Value(source.height), + durationMs: Value(source.durationMs), + isFavorite: Value(source.isFavorite), + visibility: const Value(AssetVisibility.timeline), + isEdited: Value(source.isEdited), + ), + mode: InsertMode.insertOrIgnore, + ); + } + Future addUsers(String albumId, List userIds) { final albumUsers = userIds.map( (assetId) => RemoteAlbumUserEntityCompanion( diff --git a/mobile/lib/presentation/pages/drift_album.page.dart b/mobile/lib/presentation/pages/drift_album.page.dart index c9fed636b4..47a4625f87 100644 --- a/mobile/lib/presentation/pages/drift_album.page.dart +++ b/mobile/lib/presentation/pages/drift_album.page.dart @@ -37,6 +37,7 @@ class _DriftAlbumsPageState extends ConsumerState { final scrollView = CustomScrollView( controller: _scrollController, + physics: const AlwaysScrollableScrollPhysics(), slivers: [ ImmichSliverAppBar( snap: false, diff --git a/mobile/lib/presentation/pages/drift_asset_selection_timeline.page.dart b/mobile/lib/presentation/pages/drift_asset_selection_timeline.page.dart index 19f813cdb5..a12ca4932b 100644 --- a/mobile/lib/presentation/pages/drift_asset_selection_timeline.page.dart +++ b/mobile/lib/presentation/pages/drift_asset_selection_timeline.page.dart @@ -5,7 +5,6 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; -import 'package:immich_mobile/providers/user.provider.dart'; @RoutePage() class DriftAssetSelectionTimelinePage extends ConsumerWidget { @@ -22,17 +21,13 @@ class DriftAssetSelectionTimelinePage extends ConsumerWidget { ), ), timelineServiceProvider.overrideWith((ref) { - final user = ref.watch(currentUserProvider); - if (user == null) { - throw Exception('User must be logged in to access asset selection timeline'); - } - - final timelineService = ref.watch(timelineFactoryProvider).remoteAssets(user.id); + final timelineUsers = ref.watch(timelineUsersProvider).valueOrNull ?? []; + final timelineService = ref.watch(timelineFactoryProvider).main(timelineUsers); ref.onDispose(timelineService.dispose); return timelineService; }), ], - child: const Timeline(), + child: const Timeline(showStorageIndicator: true), ); } } diff --git a/mobile/lib/presentation/pages/drift_create_album.page.dart b/mobile/lib/presentation/pages/drift_create_album.page.dart index f1cbdb13ff..e6ff10fd59 100644 --- a/mobile/lib/presentation/pages/drift_create_album.page.dart +++ b/mobile/lib/presentation/pages/drift_create_album.page.dart @@ -179,17 +179,14 @@ class _DriftCreateAlbumPageState extends ConsumerState { } final album = await ref - .watch(remoteAlbumProvider.notifier) - .createAlbum( + .read(remoteAlbumProvider.notifier) + .createAlbumWithAssets( title: title, description: albumDescriptionController.text.trim(), - assetIds: selectedAssets.map((asset) { - final remoteAsset = asset as RemoteAsset; - return remoteAsset.id; - }).toList(), + assets: selectedAssets, ); - if (album != null) { + if (album != null && context.mounted) { unawaited(context.replaceRoute(RemoteAlbumRoute(album: album))); } } diff --git a/mobile/lib/presentation/pages/drift_remote_album.page.dart b/mobile/lib/presentation/pages/drift_remote_album.page.dart index ba9ccf2ffd..0046ce4097 100644 --- a/mobile/lib/presentation/pages/drift_remote_album.page.dart +++ b/mobile/lib/presentation/pages/drift_remote_album.page.dart @@ -49,17 +49,9 @@ class _RemoteAlbumPageState extends ConsumerState { return; } - final added = await ref - .read(remoteAlbumProvider.notifier) - .addAssets( - _album.id, - newAssets.map((asset) { - final remoteAsset = asset as RemoteAsset; - return remoteAsset.id; - }).toList(), - ); + final added = await ref.read(remoteAlbumProvider.notifier).addAssetsToAlbum(_album.id, newAssets); - if (added > 0) { + if (added > 0 && context.mounted) { ImmichToast.show( context: context, msg: "assets_added_to_album_count".t(context: context, args: {'count': added.toString()}), diff --git a/mobile/lib/providers/infrastructure/album.provider.dart b/mobile/lib/providers/infrastructure/album.provider.dart index 1ddabc1604..379e7b3101 100644 --- a/mobile/lib/providers/infrastructure/album.provider.dart +++ b/mobile/lib/providers/infrastructure/album.provider.dart @@ -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( (ref) => DriftLocalAlbumRepository(ref.watch(driftProvider)), @@ -33,7 +34,11 @@ final remoteAlbumRepository = Provider( ); final remoteAlbumServiceProvider = Provider( - (ref) => RemoteAlbumService(ref.watch(remoteAlbumRepository), ref.watch(driftAlbumApiRepositoryProvider)), + (ref) => RemoteAlbumService( + ref.watch(remoteAlbumRepository), + ref.watch(driftAlbumApiRepositoryProvider), + ref.watch(foregroundUploadServiceProvider), + ), dependencies: [remoteAlbumRepository], ); diff --git a/mobile/lib/providers/infrastructure/remote_album.provider.dart b/mobile/lib/providers/infrastructure/remote_album.provider.dart index 949e6d747e..38b4a7461c 100644 --- a/mobile/lib/providers/infrastructure/remote_album.provider.dart +++ b/mobile/lib/providers/infrastructure/remote_album.provider.dart @@ -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 { } } + /// 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 createAlbumWithAssets({ + required String title, + String? description, + Iterable 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 updateAlbum( String albumId, { String? name, @@ -157,6 +197,73 @@ class RemoteAlbumNotifier extends Notifier { 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 addAssetsToAlbum(String albumId, Iterable 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 _runWithProgressBridge( + List localAssets, + Future Function(UploadCallbacks callbacks) action, + ) async { + if (localAssets.isEmpty) { + return action(const UploadCallbacks()); + } + + final progressNotifier = ref.read(assetUploadProgressProvider.notifier); + final cancelToken = Completer(); + 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 addUsers(String albumId, List userIds) { return _remoteAlbumService.addUsers(albumId: albumId, userIds: userIds); }