feat: drift manual upload (#20101)

This commit is contained in:
Alex 2025-07-23 09:20:52 -05:00 committed by GitHub
parent 08122d6871
commit 1a35a01149
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 114 additions and 9 deletions

View File

@ -1941,11 +1941,13 @@
"updated_at": "Updated", "updated_at": "Updated",
"updated_password": "Updated password", "updated_password": "Updated password",
"upload": "Upload", "upload": "Upload",
"upload_action_prompt": "{count} queued for upload",
"upload_concurrency": "Upload concurrency", "upload_concurrency": "Upload concurrency",
"upload_details": "Upload Details", "upload_details": "Upload Details",
"upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?", "upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?",
"upload_dialog_title": "Upload Asset", "upload_dialog_title": "Upload Asset",
"upload_errors": "Upload completed with {count, plural, one {# error} other {# errors}}, refresh the page to see new upload assets.", "upload_errors": "Upload completed with {count, plural, one {# error} other {# errors}}, refresh the page to see new upload assets.",
"upload_finished": "Upload finished",
"upload_progress": "Remaining {remaining, number} - Processed {processed, number}/{total, number}", "upload_progress": "Remaining {remaining, number} - Processed {processed, number}/{total, number}",
"upload_skipped_duplicates": "Skipped {count, plural, one {# duplicate asset} other {# duplicate assets}}", "upload_skipped_duplicates": "Skipped {count, plural, one {# duplicate asset} other {# duplicate assets}}",
"upload_status_duplicates": "Duplicates", "upload_status_duplicates": "Duplicates",
@ -1954,6 +1956,7 @@
"upload_success": "Upload success, refresh the page to see new upload assets.", "upload_success": "Upload success, refresh the page to see new upload assets.",
"upload_to_immich": "Upload to Immich ({count})", "upload_to_immich": "Upload to Immich ({count})",
"uploading": "Uploading", "uploading": "Uploading",
"uploading_media": "Uploading media",
"url": "URL", "url": "URL",
"usage": "Usage", "usage": "Usage",
"use_biometric": "Use biometric", "use_biometric": "Use biometric",

View File

@ -141,6 +141,11 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
_db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id), _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id),
useColumns: false, useColumns: false,
), ),
leftOuterJoin(
_db.remoteAssetEntity,
_db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum),
useColumns: false,
),
]) ])
..addColumns([assetCountExp, dateExp]) ..addColumns([assetCountExp, dateExp])
..where(_db.localAlbumAssetEntity.albumId.equals(albumId)) ..where(_db.localAlbumAssetEntity.albumId.equals(albumId))

View File

@ -203,6 +203,19 @@ class ImmichAppState extends ConsumerState<ImmichApp>
), ),
progressBar: true, progressBar: true,
); );
FileDownloader().configureNotificationForGroup(
kManualUploadGroup,
running: TaskNotification(
'uploading_media'.tr(),
'${'file_name'.tr()}: {displayName}',
),
complete: TaskNotification(
'upload_finished'.tr(),
'${'file_name'.tr()}: {displayName}',
),
progressBar: true,
);
} }
Future<DeepLink> _deepLinkBuilder(PlatformDeepLink deepLink) async { Future<DeepLink> _deepLinkBuilder(PlatformDeepLink deepLink) async {

View File

@ -1,16 +1,50 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class UploadActionButton extends ConsumerWidget { class UploadActionButton extends ConsumerWidget {
const UploadActionButton({super.key}); final ActionSource source;
const UploadActionButton({super.key, required this.source});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final result = await ref.read(actionProvider.notifier).upload(source);
final successMessage = 'upload_action_prompt'.t(
context: context,
args: {'count': result.count.toString()},
);
if (context.mounted) {
ImmichToast.show(
context: context,
msg: result.success
? successMessage
: 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
);
ref.read(multiSelectProvider.notifier).reset();
}
}
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
return BaseActionButton( return BaseActionButton(
iconData: Icons.backup_outlined, iconData: Icons.backup_outlined,
label: "upload".t(context: context), label: "upload".t(context: context),
onPressed: () => _onTap(context, ref),
); );
} }
} }

View File

@ -5,6 +5,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
@ -37,6 +38,8 @@ class ViewerBottomBar extends ConsumerWidget {
final actions = <Widget>[ final actions = <Widget>[
const ShareActionButton(source: ActionSource.viewer), const ShareActionButton(source: ActionSource.viewer),
if (asset.isLocalOnly)
const UploadActionButton(source: ActionSource.viewer),
if (asset.hasRemote && isOwner) if (asset.hasRemote && isOwner)
const ArchiveActionButton(source: ActionSource.viewer), const ArchiveActionButton(source: ActionSource.viewer),
]; ];

View File

@ -63,7 +63,7 @@ class AssetDetailBottomSheet extends ConsumerWidget {
], ],
if (asset.storage == AssetState.local) ...[ if (asset.storage == AssetState.local) ...[
const DeleteLocalActionButton(source: ActionSource.viewer), const DeleteLocalActionButton(source: ActionSource.viewer),
const UploadActionButton(), const UploadActionButton(source: ActionSource.timeline),
], ],
]; ];

View File

@ -53,7 +53,7 @@ class ArchiveBottomSheet extends ConsumerWidget {
], ],
if (multiselect.hasLocal) ...[ if (multiselect.hasLocal) ...[
const DeleteLocalActionButton(source: ActionSource.timeline), const DeleteLocalActionButton(source: ActionSource.timeline),
const UploadActionButton(), const UploadActionButton(source: ActionSource.timeline),
], ],
], ],
); );

View File

@ -53,7 +53,7 @@ class FavoriteBottomSheet extends ConsumerWidget {
], ],
if (multiselect.hasLocal) ...[ if (multiselect.hasLocal) ...[
const DeleteLocalActionButton(source: ActionSource.timeline), const DeleteLocalActionButton(source: ActionSource.timeline),
const UploadActionButton(), const UploadActionButton(source: ActionSource.timeline),
], ],
], ],
); );

View File

@ -56,7 +56,7 @@ class GeneralBottomSheet extends ConsumerWidget {
], ],
if (multiselect.hasLocal) ...[ if (multiselect.hasLocal) ...[
const DeleteLocalActionButton(source: ActionSource.timeline), const DeleteLocalActionButton(source: ActionSource.timeline),
const UploadActionButton(), const UploadActionButton(source: ActionSource.timeline),
], ],
], ],
); );

View File

@ -18,7 +18,7 @@ class LocalAlbumBottomSheet extends ConsumerWidget {
actions: [ actions: [
ShareActionButton(source: ActionSource.timeline), ShareActionButton(source: ActionSource.timeline),
DeleteLocalActionButton(source: ActionSource.timeline), DeleteLocalActionButton(source: ActionSource.timeline),
UploadActionButton(), UploadActionButton(source: ActionSource.timeline),
], ],
); );
} }

View File

@ -56,7 +56,7 @@ class RemoteAlbumBottomSheet extends ConsumerWidget {
], ],
if (multiselect.hasLocal) ...[ if (multiselect.hasLocal) ...[
const DeleteLocalActionButton(source: ActionSource.timeline), const DeleteLocalActionButton(source: ActionSource.timeline),
const UploadActionButton(), const UploadActionButton(source: ActionSource.timeline),
], ],
RemoveFromAlbumActionButton( RemoveFromAlbumActionButton(
source: ActionSource.timeline, source: ActionSource.timeline,

View File

@ -5,6 +5,7 @@ import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asse
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/action.service.dart'; import 'package:immich_mobile/services/action.service.dart';
import 'package:immich_mobile/services/drift_backup.service.dart';
import 'package:immich_mobile/services/timeline.service.dart'; import 'package:immich_mobile/services/timeline.service.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
@ -32,12 +33,14 @@ class ActionResult {
class ActionNotifier extends Notifier<void> { class ActionNotifier extends Notifier<void> {
final Logger _logger = Logger('ActionNotifier'); final Logger _logger = Logger('ActionNotifier');
late ActionService _service; late ActionService _service;
late DriftBackupService _backupService;
ActionNotifier() : super(); ActionNotifier() : super();
@override @override
void build() { void build() {
_service = ref.watch(actionServiceProvider); _service = ref.watch(actionServiceProvider);
_backupService = ref.watch(driftBackupServiceProvider);
} }
List<String> _getRemoteIdsForSource(ActionSource source) { List<String> _getRemoteIdsForSource(ActionSource source) {
@ -368,6 +371,21 @@ class ActionNotifier extends Notifier<void> {
); );
} }
} }
Future<ActionResult> upload(ActionSource source) async {
final assets = _getAssets(source).whereType<LocalAsset>().toList();
try {
await _backupService.manualBackup(assets);
return ActionResult(count: assets.length, success: true);
} catch (error, stack) {
_logger.severe('Failed manually upload assets', error, stack);
return ActionResult(
count: assets.length,
success: false,
error: error.toString(),
);
}
}
} }
extension on Iterable<RemoteAsset> { extension on Iterable<RemoteAsset> {

View File

@ -20,6 +20,11 @@ class UploadRepository {
taskStatusCallback: (update) => onUploadStatus?.call(update), taskStatusCallback: (update) => onUploadStatus?.call(update),
taskProgressCallback: (update) => onTaskProgress?.call(update), taskProgressCallback: (update) => onTaskProgress?.call(update),
); );
FileDownloader().registerCallbacks(
group: kManualUploadGroup,
taskStatusCallback: (_) => {},
taskProgressCallback: (_) => {},
);
} }
void enqueueAll(List<UploadTask> tasks) { void enqueueAll(List<UploadTask> tasks) {

View File

@ -26,6 +26,7 @@ final driftBackupServiceProvider = Provider<DriftBackupService>(
), ),
); );
// TODO: Rename to UploadService after removing Isar
class DriftBackupService { class DriftBackupService {
DriftBackupService( DriftBackupService(
this._backupRepository, this._backupRepository,
@ -56,6 +57,24 @@ class DriftBackupService {
return _backupRepository.getBackupCount(userId); return _backupRepository.getBackupCount(userId);
} }
Future<void> manualBackup(List<LocalAsset> localAssets) async {
List<UploadTask> tasks = [];
for (final asset in localAssets) {
final task = await _getUploadTask(
asset,
group: kManualUploadGroup,
priority: 1, // High priority after upload motion photo part
);
if (task != null) {
tasks.add(task);
}
}
if (tasks.isNotEmpty) {
_uploadService.enqueueTasks(tasks);
}
}
Future<void> backup( Future<void> backup(
String userId, String userId,
void Function(EnqueueStatus status) onEnqueueTasks, void Function(EnqueueStatus status) onEnqueueTasks,
@ -147,7 +166,11 @@ class DriftBackupService {
} }
} }
Future<UploadTask?> _getUploadTask(LocalAsset asset) async { Future<UploadTask?> _getUploadTask(
LocalAsset asset, {
String group = kBackupGroup,
int? priority,
}) async {
final entity = await _storageRepository.getAssetEntityForAsset(asset); final entity = await _storageRepository.getAssetEntityForAsset(asset);
if (entity == null) { if (entity == null) {
return null; return null;
@ -193,7 +216,8 @@ class DriftBackupService {
originalFileName: originalFileName, originalFileName: originalFileName,
deviceAssetId: asset.id, deviceAssetId: asset.id,
metadata: metadata, metadata: metadata,
group: kBackupGroup, group: group,
priority: priority,
); );
} }