mirror of
https://github.com/immich-app/immich.git
synced 2026-07-21 21:34:17 +03:00
Compare commits
7 Commits
renovate/g
...
chore/test
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
975a1572e4 | ||
|
|
832294818d | ||
|
|
d5adfb97dd | ||
|
|
b70cb58bc8 | ||
|
|
943bfafb7a | ||
|
|
ee4bd3f833 | ||
|
|
7a7303aceb |
@@ -43,15 +43,16 @@ class ConnectivityApiImpl: ConnectivityApi {
|
||||
capabilities.append(.vpn)
|
||||
}
|
||||
|
||||
// Determine if connection is unmetered:
|
||||
// - Must be on WiFi (not cellular)
|
||||
// - Must not be expensive (rules out personal hotspot)
|
||||
// - Must not be constrained (Low Data Mode)
|
||||
// Note: VPN over cellular should still be considered metered
|
||||
// Determine if connection is unmetered from the OS metered flags rather than
|
||||
// the interface type, so wired ethernet (iPhone USB adapters, Apple Silicon
|
||||
// Macs) is treated as unmetered like Wi-Fi:
|
||||
// - Not on cellular
|
||||
// - Not expensive (also rules out cellular and personal hotspot)
|
||||
// - Not constrained (Low Data Mode)
|
||||
// Note: VPN over cellular stays metered because the path is still expensive.
|
||||
let isOnCellular = path.usesInterfaceType(.cellular)
|
||||
let isOnWifi = path.usesInterfaceType(.wifi)
|
||||
|
||||
if isOnWifi && !isOnCellular && !path.isExpensive && !path.isConstrained {
|
||||
|
||||
if !isOnCellular && !path.isExpensive && !path.isConstrained {
|
||||
capabilities.append(.unmetered)
|
||||
}
|
||||
|
||||
|
||||
@@ -159,8 +159,8 @@ class RemoteAlbumService {
|
||||
return updatedAlbum;
|
||||
}
|
||||
|
||||
FutureOr<(DateTime, DateTime)> getDateRange(String albumId) {
|
||||
return _repository.getDateRange(albumId);
|
||||
Stream<(DateTime, DateTime)> watchDateRange(String albumId) {
|
||||
return _repository.watchDateRange(albumId);
|
||||
}
|
||||
|
||||
Future<List<UserDto>> getSharedUsers(String albumId) {
|
||||
@@ -175,12 +175,12 @@ class RemoteAlbumService {
|
||||
return _repository.getAssets(albumId);
|
||||
}
|
||||
|
||||
Future<int> addAssets({required String albumId, required List<String> assetIds}) async {
|
||||
Future<({int added, int failed})> addAssets({required String albumId, required List<String> assetIds}) async {
|
||||
final album = await _albumApiRepository.addAssets(albumId, assetIds);
|
||||
|
||||
await _repository.addAssets(albumId, album.added);
|
||||
|
||||
return album.added.length;
|
||||
return (added: album.added.length, failed: album.failed.length);
|
||||
}
|
||||
|
||||
/// !TODO The name here is not clear as we have addAssets method above,
|
||||
@@ -196,7 +196,7 @@ class RemoteAlbumService {
|
||||
}) async {
|
||||
int addedCount = 0;
|
||||
if (candidates.remoteAssetIds.isNotEmpty) {
|
||||
addedCount += await addAssets(albumId: albumId, assetIds: candidates.remoteAssetIds);
|
||||
addedCount += (await addAssets(albumId: albumId, assetIds: candidates.remoteAssetIds)).added;
|
||||
}
|
||||
if (candidates.localAssetsToUpload.isNotEmpty) {
|
||||
addedCount += await _uploadAndAddLocals(
|
||||
|
||||
@@ -217,7 +217,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
|
||||
});
|
||||
}
|
||||
|
||||
FutureOr<(DateTime, DateTime)> getDateRange(String albumId) {
|
||||
Stream<(DateTime, DateTime)> watchDateRange(String albumId) {
|
||||
final query = _db.remoteAlbumAssetEntity.selectOnly()
|
||||
..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId))
|
||||
..addColumns([_db.remoteAssetEntity.createdAt.min(), _db.remoteAssetEntity.createdAt.max()])
|
||||
@@ -229,7 +229,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
|
||||
final minDate = row.read(_db.remoteAssetEntity.createdAt.min());
|
||||
final maxDate = row.read(_db.remoteAssetEntity.createdAt.max());
|
||||
return (minDate ?? DateTime.now(), maxDate ?? DateTime.now());
|
||||
}).getSingle();
|
||||
}).watchSingle();
|
||||
}
|
||||
|
||||
Future<List<UserDto>> getSharedUsers(String albumId) async {
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_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/unarchive_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
@@ -154,12 +155,8 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.count == 0) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {'album': album.name}),
|
||||
);
|
||||
} else {
|
||||
// Only report the failure when nothing was added; if some succeeded we show "added".
|
||||
if (result.count > 0) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'add_to_album_bottom_sheet_added'.tr(namedArgs: {'album': album.name}),
|
||||
@@ -167,6 +164,17 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
|
||||
|
||||
// Refresh the "Appears in" list on the asset's info panel.
|
||||
ref.invalidate(albumsContainingAssetProvider(latest.remoteId!));
|
||||
} else if (result.failedCount > 0) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'assets_cannot_be_added_to_album_count'.t(context: context, args: {'count': result.failedCount}),
|
||||
toastType: ToastType.error,
|
||||
);
|
||||
} else {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {'album': album.name}),
|
||||
);
|
||||
}
|
||||
|
||||
if (!context.mounted) {
|
||||
|
||||
@@ -41,7 +41,7 @@ class FavoriteBottomSheet extends ConsumerWidget {
|
||||
}
|
||||
|
||||
final remoteAssets = selectedAssets.whereType<RemoteAsset>();
|
||||
final addedCount = await ref
|
||||
final result = await ref
|
||||
.read(remoteAlbumProvider.notifier)
|
||||
.addAssets(album.id, remoteAssets.map((e) => e.id).toList());
|
||||
|
||||
@@ -52,15 +52,22 @@ class FavoriteBottomSheet extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
if (addedCount != remoteAssets.length) {
|
||||
// Only report the failure when nothing was added; if some succeeded we show "added".
|
||||
if (result.added > 0) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'add_to_album_bottom_sheet_already_exists'.t(args: {"album": album.name}),
|
||||
msg: 'add_to_album_bottom_sheet_added'.t(args: {"album": album.name}),
|
||||
);
|
||||
} else if (result.failed > 0) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'assets_cannot_be_added_to_album_count'.t(context: context, args: {'count': result.failed}),
|
||||
toastType: ToastType.error,
|
||||
);
|
||||
} else {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'add_to_album_bottom_sheet_added'.t(args: {"album": album.name}),
|
||||
msg: 'add_to_album_bottom_sheet_already_exists'.t(args: {"album": album.name}),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/models/download/download_state.model.dart';
|
||||
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
|
||||
import 'package:immich_mobile/services/download.service.dart';
|
||||
|
||||
class DownloadStateNotifier extends StateNotifier<DownloadState> {
|
||||
@@ -17,79 +14,9 @@ class DownloadStateNotifier extends StateNotifier<DownloadState> {
|
||||
taskProgress: <String, DownloadInfo>{},
|
||||
),
|
||||
) {
|
||||
_downloadService.onImageDownloadStatus = _downloadImageCallback;
|
||||
_downloadService.onVideoDownloadStatus = _downloadVideoCallback;
|
||||
_downloadService.onLivePhotoDownloadStatus = _downloadLivePhotoCallback;
|
||||
_downloadService.onTaskProgress = _taskProgressCallback;
|
||||
}
|
||||
|
||||
void _updateDownloadStatus(String taskId, TaskStatus status) {
|
||||
if (status == TaskStatus.canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
taskProgress: <String, DownloadInfo>{}
|
||||
..addAll(state.taskProgress)
|
||||
..addAll({
|
||||
taskId: DownloadInfo(
|
||||
progress: state.taskProgress[taskId]?.progress ?? 0,
|
||||
fileName: state.taskProgress[taskId]?.fileName ?? '',
|
||||
status: status,
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Download live photo callback
|
||||
void _downloadLivePhotoCallback(TaskStatusUpdate update) {
|
||||
_updateDownloadStatus(update.task.taskId, update.status);
|
||||
|
||||
switch (update.status) {
|
||||
case TaskStatus.complete:
|
||||
if (update.task.metaData.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final livePhotosId = LivePhotosMetadata.fromJson(update.task.metaData).id;
|
||||
_downloadService.saveLivePhotos(update.task, livePhotosId);
|
||||
_onDownloadComplete(update.task.taskId);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Download image callback
|
||||
void _downloadImageCallback(TaskStatusUpdate update) {
|
||||
_updateDownloadStatus(update.task.taskId, update.status);
|
||||
|
||||
switch (update.status) {
|
||||
case TaskStatus.complete:
|
||||
_downloadService.saveImageWithPath(update.task);
|
||||
_onDownloadComplete(update.task.taskId);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Download video callback
|
||||
void _downloadVideoCallback(TaskStatusUpdate update) {
|
||||
_updateDownloadStatus(update.task.taskId, update.status);
|
||||
|
||||
switch (update.status) {
|
||||
case TaskStatus.complete:
|
||||
_downloadService.saveVideo(update.task);
|
||||
_onDownloadComplete(update.task.taskId);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _taskProgressCallback(TaskProgressUpdate update) {
|
||||
// Ignore if the task is canceled or completed
|
||||
if (update.progress == -2 || update.progress == -1) {
|
||||
@@ -110,20 +37,6 @@ class DownloadStateNotifier extends StateNotifier<DownloadState> {
|
||||
);
|
||||
}
|
||||
|
||||
void _onDownloadComplete(String id) {
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
state = state.copyWith(
|
||||
taskProgress: <String, DownloadInfo>{}
|
||||
..addAll(state.taskProgress)
|
||||
..remove(id),
|
||||
);
|
||||
|
||||
if (state.taskProgress.isEmpty) {
|
||||
state = state.copyWith(showProgress: false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void cancelDownload(String id) async {
|
||||
final isCanceled = await _downloadService.cancelDownload(id);
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
@@ -10,7 +9,6 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
|
||||
import 'package:immich_mobile/domain/services/asset.service.dart';
|
||||
import 'package:immich_mobile/domain/services/remote_album.service.dart';
|
||||
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
@@ -23,7 +21,6 @@ import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/action.service.dart';
|
||||
import 'package:immich_mobile/services/download.service.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart';
|
||||
@@ -37,18 +34,25 @@ class ActionResult {
|
||||
final bool success;
|
||||
final String? error;
|
||||
final List<String> remoteAssetIds;
|
||||
final int failedCount;
|
||||
|
||||
const ActionResult({required this.count, required this.success, this.error, this.remoteAssetIds = const []});
|
||||
const ActionResult({
|
||||
required this.count,
|
||||
required this.success,
|
||||
this.error,
|
||||
this.remoteAssetIds = const [],
|
||||
this.failedCount = 0,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() => 'ActionResult(count: $count, success: $success, error: $error, remoteAssetIds: $remoteAssetIds)';
|
||||
String toString() =>
|
||||
'ActionResult(count: $count, success: $success, error: $error, remoteAssetIds: $remoteAssetIds, failedCount: $failedCount)';
|
||||
}
|
||||
|
||||
class ActionNotifier extends Notifier<void> {
|
||||
final Logger _logger = Logger('ActionNotifier');
|
||||
late ActionService _service;
|
||||
late ForegroundUploadService _foregroundUploadService;
|
||||
late DownloadService _downloadService;
|
||||
late AssetService _assetService;
|
||||
|
||||
ActionNotifier() : super();
|
||||
@@ -58,29 +62,6 @@ class ActionNotifier extends Notifier<void> {
|
||||
_foregroundUploadService = ref.watch(foregroundUploadServiceProvider);
|
||||
_service = ref.watch(actionServiceProvider);
|
||||
_assetService = ref.watch(assetServiceProvider);
|
||||
_downloadService = ref.watch(downloadServiceProvider);
|
||||
_downloadService.onImageDownloadStatus = _downloadImageCallback;
|
||||
_downloadService.onVideoDownloadStatus = _downloadVideoCallback;
|
||||
_downloadService.onLivePhotoDownloadStatus = _downloadLivePhotoCallback;
|
||||
}
|
||||
|
||||
void _downloadImageCallback(TaskStatusUpdate update) {
|
||||
if (update.status == TaskStatus.complete) {
|
||||
_downloadService.saveImageWithPath(update.task);
|
||||
}
|
||||
}
|
||||
|
||||
void _downloadVideoCallback(TaskStatusUpdate update) {
|
||||
if (update.status == TaskStatus.complete) {
|
||||
_downloadService.saveVideo(update.task);
|
||||
}
|
||||
}
|
||||
|
||||
void _downloadLivePhotoCallback(TaskStatusUpdate update) async {
|
||||
if (update.status == TaskStatus.complete) {
|
||||
final livePhotosId = LivePhotosMetadata.fromJson(update.task.metaData).id;
|
||||
unawaited(_downloadService.saveLivePhotos(update.task, livePhotosId));
|
||||
}
|
||||
}
|
||||
|
||||
List<String> _getRemoteIdsForSource(ActionSource source) {
|
||||
@@ -393,9 +374,12 @@ class ActionNotifier extends Notifier<void> {
|
||||
final albumNotifier = ref.read(remoteAlbumProvider.notifier);
|
||||
|
||||
int addedRemote = 0;
|
||||
int failedRemote = 0;
|
||||
if (remoteIds.isNotEmpty) {
|
||||
try {
|
||||
addedRemote = await albumNotifier.addAssets(album.id, remoteIds);
|
||||
final result = await albumNotifier.addAssets(album.id, remoteIds);
|
||||
addedRemote = result.added;
|
||||
failedRemote = result.failed;
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to add assets to album ${album.id}', error, stack);
|
||||
return ActionResult(count: 0, success: false, error: error.toString());
|
||||
@@ -409,7 +393,7 @@ class ActionNotifier extends Notifier<void> {
|
||||
}
|
||||
|
||||
if (localAssets.isEmpty) {
|
||||
return ActionResult(count: addedRemote, success: true);
|
||||
return ActionResult(count: addedRemote, success: true, failedCount: failedRemote);
|
||||
}
|
||||
|
||||
final uploadResult = await upload(
|
||||
@@ -424,6 +408,7 @@ class ActionNotifier extends Notifier<void> {
|
||||
count: addedRemote + uploadResult.count,
|
||||
success: uploadResult.success,
|
||||
error: uploadResult.error,
|
||||
failedCount: failedRemote,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -200,12 +200,12 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
|
||||
return _remoteAlbumService.getAssets(albumId);
|
||||
}
|
||||
|
||||
Future<int> addAssets(String albumId, List<String> assetIds) async {
|
||||
final added = await _remoteAlbumService.addAssets(albumId: albumId, assetIds: assetIds);
|
||||
if (added > 0) {
|
||||
Future<({int added, int failed})> addAssets(String albumId, List<String> assetIds) async {
|
||||
final result = await _remoteAlbumService.addAssets(albumId: albumId, assetIds: assetIds);
|
||||
if (result.added > 0) {
|
||||
await _refreshAlbumInState(albumId);
|
||||
}
|
||||
return added;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Links a freshly-uploaded local asset to an album using its new remote ID,
|
||||
@@ -313,9 +313,9 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
|
||||
}
|
||||
}
|
||||
|
||||
final remoteAlbumDateRangeProvider = FutureProvider.family<(DateTime, DateTime), String>((ref, albumId) async {
|
||||
final remoteAlbumDateRangeProvider = StreamProvider.autoDispose.family<(DateTime, DateTime), String>((ref, albumId) {
|
||||
final service = ref.watch(remoteAlbumServiceProvider);
|
||||
return service.getDateRange(albumId);
|
||||
return service.watchDateRange(albumId);
|
||||
});
|
||||
|
||||
final remoteAlbumSharedUsersProvider = FutureProvider.autoDispose.family<List<UserDto>, String>((ref, albumId) async {
|
||||
|
||||
@@ -103,6 +103,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
||||
socket.on('AssetUploadReadyV2', _handleSyncAssetUploadReadyV2);
|
||||
socket.on('AssetEditReadyV1', _handleSyncAssetEditReadyV1);
|
||||
socket.on('AssetEditReadyV2', _handleSyncAssetEditReadyV2);
|
||||
socket.on('on_album_update', _handleAlbumUpdate);
|
||||
socket.on('on_config_update', _handleOnConfigUpdate);
|
||||
socket.on('on_new_release', _handleReleaseUpdates);
|
||||
} catch (e) {
|
||||
@@ -184,6 +185,10 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
||||
unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditV1(data));
|
||||
}
|
||||
|
||||
void _handleAlbumUpdate(dynamic _) {
|
||||
unawaited(_ref.read(backgroundSyncProvider).syncRemote());
|
||||
}
|
||||
|
||||
void _handleSyncAssetEditReadyV2(dynamic data) {
|
||||
unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditV2(data));
|
||||
}
|
||||
|
||||
@@ -27,10 +27,12 @@ class DownloadRepository {
|
||||
|
||||
void Function(TaskStatusUpdate)? onVideoDownloadStatus;
|
||||
|
||||
void Function(TaskStatusUpdate)? onLivePhotoDownloadStatus;
|
||||
|
||||
void Function(TaskProgressUpdate)? onTaskProgress;
|
||||
|
||||
// #29900: `taskStatusCallback` is called before the DB has been updated, causing a race between the two Live Photo tasks
|
||||
// This callback instead listens directly to DB updates
|
||||
void Function(TaskRecord)? onLivePhotoRecordComplete;
|
||||
|
||||
DownloadRepository() {
|
||||
_downloader.registerCallbacks(
|
||||
group: kDownloadGroupImage,
|
||||
@@ -46,9 +48,12 @@ class DownloadRepository {
|
||||
|
||||
_downloader.registerCallbacks(
|
||||
group: kDownloadGroupLivePhoto,
|
||||
taskStatusCallback: (update) => onLivePhotoDownloadStatus?.call(update),
|
||||
taskProgressCallback: (update) => onTaskProgress?.call(update),
|
||||
);
|
||||
|
||||
_downloader.database.updates
|
||||
.where((record) => record.group == kDownloadGroupLivePhoto && record.status == TaskStatus.complete)
|
||||
.listen((record) => onLivePhotoRecordComplete?.call(record));
|
||||
}
|
||||
|
||||
Future<List<bool>> downloadAll(List<DownloadTask> tasks) {
|
||||
|
||||
@@ -59,7 +59,7 @@ class DriftAlbumApiRepository extends ApiRepository {
|
||||
for (final dto in response) {
|
||||
if (dto.success) {
|
||||
added.add(dto.id);
|
||||
} else {
|
||||
} else if (dto.error.orElse(null) != BulkIdErrorReason.duplicate) {
|
||||
failed.add(dto.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
|
||||
@@ -18,14 +20,27 @@ class DownloadService {
|
||||
final Logger _log = Logger("DownloadService");
|
||||
void Function(TaskStatusUpdate)? onImageDownloadStatus;
|
||||
void Function(TaskStatusUpdate)? onVideoDownloadStatus;
|
||||
void Function(TaskStatusUpdate)? onLivePhotoDownloadStatus;
|
||||
void Function(TaskProgressUpdate)? onTaskProgress;
|
||||
|
||||
/// Active Live Photo IDs undergoing saving
|
||||
final Set<String> _savingLivePhotoIds = {};
|
||||
|
||||
DownloadService(this._fileMediaRepository, this._downloadRepository) {
|
||||
_downloadRepository.onImageDownloadStatus = _onImageDownloadCallback;
|
||||
_downloadRepository.onVideoDownloadStatus = _onVideoDownloadCallback;
|
||||
_downloadRepository.onLivePhotoDownloadStatus = _onLivePhotoDownloadCallback;
|
||||
_downloadRepository.onTaskProgress = _onTaskProgressCallback;
|
||||
_downloadRepository.onLivePhotoRecordComplete = _onLivePhotoRecordComplete;
|
||||
|
||||
unawaited(_savePreviouslyCompletedLivePhotos());
|
||||
}
|
||||
|
||||
Future<void> _savePreviouslyCompletedLivePhotos() async {
|
||||
// Specifically fetch Live Photo video components only, as to not double fetch assets
|
||||
final records = await _downloadRepository.getLiveVideoTasks();
|
||||
final completedIds = records.map((record) => LivePhotosMetadata.fromJson(record.task.metaData).id).toSet();
|
||||
for (final id in completedIds) {
|
||||
await _saveLivePhotos(id);
|
||||
}
|
||||
}
|
||||
|
||||
void _onTaskProgressCallback(TaskProgressUpdate update) {
|
||||
@@ -33,18 +48,27 @@ class DownloadService {
|
||||
}
|
||||
|
||||
void _onImageDownloadCallback(TaskStatusUpdate update) {
|
||||
if (update.status == TaskStatus.complete) {
|
||||
unawaited(_saveImageWithPath(update.task));
|
||||
}
|
||||
|
||||
onImageDownloadStatus?.call(update);
|
||||
}
|
||||
|
||||
void _onVideoDownloadCallback(TaskStatusUpdate update) {
|
||||
if (update.status == TaskStatus.complete) {
|
||||
unawaited(_saveVideo(update.task));
|
||||
}
|
||||
|
||||
onVideoDownloadStatus?.call(update);
|
||||
}
|
||||
|
||||
void _onLivePhotoDownloadCallback(TaskStatusUpdate update) {
|
||||
onLivePhotoDownloadStatus?.call(update);
|
||||
void _onLivePhotoRecordComplete(TaskRecord record) async {
|
||||
final livePhotosId = LivePhotosMetadata.fromJson(record.task.metaData).id;
|
||||
await _saveLivePhotos(livePhotosId);
|
||||
}
|
||||
|
||||
Future<bool> saveImageWithPath(Task task) async {
|
||||
Future<bool> _saveImageWithPath(Task task) async {
|
||||
final filePath = await task.filePath();
|
||||
final title = task.filename;
|
||||
final relativePath = Platform.isAndroid ? 'DCIM/Immich' : null;
|
||||
@@ -65,7 +89,7 @@ class DownloadService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> saveVideo(Task task) async {
|
||||
Future<bool> _saveVideo(Task task) async {
|
||||
final filePath = await task.filePath();
|
||||
final title = task.filename;
|
||||
final relativePath = Platform.isAndroid ? 'DCIM/Immich' : null;
|
||||
@@ -83,14 +107,21 @@ class DownloadService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> saveLivePhotos(Task task, String livePhotosId) async {
|
||||
Future<bool> _saveLivePhotos(String livePhotosId) async {
|
||||
final records = await _downloadRepository.getLiveVideoTasks();
|
||||
if (records.length < 2) {
|
||||
final imageRecord = _findTaskRecord(records, livePhotosId, LivePhotosPart.image);
|
||||
final videoRecord = _findTaskRecord(records, livePhotosId, LivePhotosPart.video);
|
||||
|
||||
if (imageRecord == null || videoRecord == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final imageRecord = _findTaskRecord(records, livePhotosId, LivePhotosPart.image);
|
||||
final videoRecord = _findTaskRecord(records, livePhotosId, LivePhotosPart.video);
|
||||
// Write semaphore for this `livePhotoId`
|
||||
if (!_savingLivePhotoIds.add(livePhotosId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final title = imageRecord.task.filename;
|
||||
final imageFilePath = await imageRecord.task.filePath();
|
||||
final videoFilePath = await videoRecord.task.filePath();
|
||||
|
||||
@@ -98,14 +129,14 @@ class DownloadService {
|
||||
final result = await _fileMediaRepository.saveLivePhoto(
|
||||
image: File(imageFilePath),
|
||||
video: File(videoFilePath),
|
||||
title: task.filename,
|
||||
title: title,
|
||||
);
|
||||
|
||||
return result != null;
|
||||
} on PlatformException catch (error, stack) {
|
||||
// Handle saving MotionPhotos on iOS
|
||||
if (error.code.startsWith('PHPhotosErrorDomain')) {
|
||||
final result = await _fileMediaRepository.saveImageWithFile(imageFilePath, title: task.filename);
|
||||
final result = await _fileMediaRepository.saveImageWithFile(imageFilePath, title: title);
|
||||
return result != null;
|
||||
}
|
||||
_log.severe("Error saving live photo", error, stack);
|
||||
@@ -125,6 +156,7 @@ class DownloadService {
|
||||
}
|
||||
|
||||
await _downloadRepository.deleteRecordsWithIds([imageRecord.task.taskId, videoRecord.task.taskId]);
|
||||
_savingLivePhotoIds.remove(livePhotosId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,8 +165,8 @@ class DownloadService {
|
||||
}
|
||||
}
|
||||
|
||||
TaskRecord _findTaskRecord(List<TaskRecord> records, String livePhotosId, LivePhotosPart part) {
|
||||
return records.firstWhere((record) {
|
||||
TaskRecord? _findTaskRecord(List<TaskRecord> records, String livePhotosId, LivePhotosPart part) {
|
||||
return records.firstWhereOrNull((record) {
|
||||
final metadata = LivePhotosMetadata.fromJson(record.task.metaData);
|
||||
return metadata.id == livePhotosId && metadata.part == part;
|
||||
});
|
||||
|
||||
@@ -12,7 +12,6 @@ import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/services/action.service.dart';
|
||||
import 'package:immich_mobile/services/download.service.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
@@ -20,8 +19,6 @@ class MockActionService extends Mock implements ActionService {}
|
||||
|
||||
class MockAssetService extends Mock implements AssetService {}
|
||||
|
||||
class MockDownloadService extends Mock implements DownloadService {}
|
||||
|
||||
class MockForegroundUploadService extends Mock implements ForegroundUploadService {}
|
||||
|
||||
class MockUserService extends Mock implements UserService {}
|
||||
@@ -67,7 +64,6 @@ void main() {
|
||||
overrides: [
|
||||
actionServiceProvider.overrideWithValue(actionService),
|
||||
assetServiceProvider.overrideWithValue(assetService),
|
||||
downloadServiceProvider.overrideWithValue(MockDownloadService()),
|
||||
foregroundUploadServiceProvider.overrideWithValue(MockForegroundUploadService()),
|
||||
currentUserProvider.overrideWith((ref) => CurrentUserProvider(userService)),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class _MockAlbumsApi extends Mock implements AlbumsApi {}
|
||||
|
||||
void main() {
|
||||
late _MockAlbumsApi api;
|
||||
late DriftAlbumApiRepository repo;
|
||||
|
||||
setUpAll(() {
|
||||
registerFallbackValue(BulkIdsDto(ids: const []));
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
api = _MockAlbumsApi();
|
||||
repo = DriftAlbumApiRepository(api);
|
||||
});
|
||||
|
||||
void stubResponse(List<BulkIdResponseDto> response) {
|
||||
when(
|
||||
() => api.addAssetsToAlbum(any(), any(), abortTrigger: any(named: 'abortTrigger')),
|
||||
).thenAnswer((_) async => response);
|
||||
}
|
||||
|
||||
test('no_permission failure surfaces as failed, not added (the #22342 bug)', () async {
|
||||
stubResponse([
|
||||
BulkIdResponseDto(id: 'a1', success: false, error: const Optional.present(BulkIdErrorReason.noPermission)),
|
||||
]);
|
||||
|
||||
final result = await repo.addAssets('album1', ['a1']);
|
||||
|
||||
expect(result.added, isEmpty);
|
||||
expect(result.failed, ['a1']);
|
||||
});
|
||||
|
||||
test('duplicate is neither added nor failed (genuinely already in album)', () async {
|
||||
stubResponse([
|
||||
BulkIdResponseDto(id: 'a1', success: false, error: const Optional.present(BulkIdErrorReason.duplicate)),
|
||||
]);
|
||||
|
||||
final result = await repo.addAssets('album1', ['a1']);
|
||||
|
||||
expect(result.added, isEmpty);
|
||||
expect(result.failed, isEmpty);
|
||||
});
|
||||
|
||||
test('success is added', () async {
|
||||
stubResponse([BulkIdResponseDto(id: 'a1', success: true)]);
|
||||
|
||||
final result = await repo.addAssets('album1', ['a1']);
|
||||
|
||||
expect(result.added, ['a1']);
|
||||
expect(result.failed, isEmpty);
|
||||
});
|
||||
|
||||
test('not_found and unknown count as failures', () async {
|
||||
stubResponse([
|
||||
BulkIdResponseDto(id: 'a1', success: false, error: const Optional.present(BulkIdErrorReason.notFound)),
|
||||
BulkIdResponseDto(id: 'a2', success: false, error: const Optional.present(BulkIdErrorReason.unknown)),
|
||||
]);
|
||||
|
||||
final result = await repo.addAssets('album1', ['a1', 'a2']);
|
||||
|
||||
expect(result.added, isEmpty);
|
||||
expect(result.failed, ['a1', 'a2']);
|
||||
});
|
||||
|
||||
test('mixed: added kept, no_permission failed, duplicate dropped', () async {
|
||||
stubResponse([
|
||||
BulkIdResponseDto(id: 'ok', success: true),
|
||||
BulkIdResponseDto(id: 'perm', success: false, error: const Optional.present(BulkIdErrorReason.noPermission)),
|
||||
BulkIdResponseDto(id: 'dup', success: false, error: const Optional.present(BulkIdErrorReason.duplicate)),
|
||||
]);
|
||||
|
||||
final result = await repo.addAssets('album1', ['ok', 'perm', 'dup']);
|
||||
|
||||
expect(result.added, ['ok']);
|
||||
expect(result.failed, ['perm']);
|
||||
});
|
||||
}
|
||||
@@ -38,7 +38,7 @@ type EventMap = {
|
||||
ConfigValidate: [{ newConfig: SystemConfig; oldConfig: SystemConfig }];
|
||||
|
||||
// album events
|
||||
AlbumUpdate: [{ id: string; recipientId: string }];
|
||||
AlbumUpdate: [{ id: string; userIds: string[]; recipientIds: string[] }];
|
||||
AlbumInvite: [{ id: string; userId: string; senderName: string }];
|
||||
|
||||
// asset events
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface ClientEventMap {
|
||||
on_asset_hidden: [string];
|
||||
on_asset_restore: [string[]];
|
||||
on_asset_stack_update: string[];
|
||||
on_album_update: [string];
|
||||
on_person_thumbnail: [string];
|
||||
on_server_version: [ServerVersionResponseDto];
|
||||
on_config_update: [];
|
||||
|
||||
@@ -841,7 +841,8 @@ describe(AlbumService.name, () => {
|
||||
expect(mocks.album.addAssetIds).toHaveBeenCalledWith(album.id, [asset1.id, asset2.id, asset3.id]);
|
||||
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', {
|
||||
id: album.id,
|
||||
recipientId: owner.id,
|
||||
userIds: album.albumUsers.map(({ user }) => user.id),
|
||||
recipientIds: [owner.id],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1091,11 +1092,13 @@ describe(AlbumService.name, () => {
|
||||
]);
|
||||
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', {
|
||||
id: album1.id,
|
||||
recipientId: owner1.id,
|
||||
userIds: album1.albumUsers.map(({ user }) => user.id),
|
||||
recipientIds: [owner1.id],
|
||||
});
|
||||
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', {
|
||||
id: album2.id,
|
||||
recipientId: owner2.id,
|
||||
userIds: album2.albumUsers.map(({ user }) => user.id),
|
||||
recipientIds: [owner2.id],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -190,11 +190,9 @@ export class AlbumService extends BaseService {
|
||||
auth.user.id,
|
||||
);
|
||||
|
||||
const allUsersExceptUs = album.albumUsers.map(({ user }) => user.id).filter((userId) => userId !== auth.user.id);
|
||||
|
||||
for (const recipientId of allUsersExceptUs) {
|
||||
await this.eventRepository.emit('AlbumUpdate', { id, recipientId });
|
||||
}
|
||||
const userIds = album.albumUsers.map(({ user }) => user.id);
|
||||
const recipientIds = userIds.filter((userId) => userId !== auth.user.id);
|
||||
await this.eventRepository.emit('AlbumUpdate', { id, userIds, recipientIds });
|
||||
}
|
||||
|
||||
return results;
|
||||
@@ -223,7 +221,7 @@ export class AlbumService extends BaseService {
|
||||
}
|
||||
|
||||
const albumAssetValues: { albumId: string; assetId: string }[] = [];
|
||||
const events: { id: string; recipients: string[] }[] = [];
|
||||
const events: { id: string; userIds: string[]; recipientIds: string[] }[] = [];
|
||||
for (const albumId of allowedAlbumIds) {
|
||||
const existingAssetIds = await this.albumRepository.getAssetIds(albumId, [...allowedAssetIds]);
|
||||
const notPresentAssetIds = [...allowedAssetIds.difference(existingAssetIds)];
|
||||
@@ -246,15 +244,14 @@ export class AlbumService extends BaseService {
|
||||
},
|
||||
auth.user.id,
|
||||
);
|
||||
const allUsersExceptUs = album.albumUsers.map(({ user }) => user.id).filter((userId) => userId !== auth.user.id);
|
||||
events.push({ id: albumId, recipients: allUsersExceptUs });
|
||||
const userIds = album.albumUsers.map(({ user }) => user.id);
|
||||
const recipientIds = userIds.filter((userId) => userId !== auth.user.id);
|
||||
events.push({ id: albumId, userIds, recipientIds });
|
||||
}
|
||||
|
||||
await this.albumRepository.addAssetIdsToAlbums(albumAssetValues);
|
||||
for (const event of events) {
|
||||
for (const recipientId of event.recipients) {
|
||||
await this.eventRepository.emit('AlbumUpdate', { id: event.id, recipientId });
|
||||
}
|
||||
await this.eventRepository.emit('AlbumUpdate', event);
|
||||
}
|
||||
|
||||
return results;
|
||||
@@ -271,8 +268,16 @@ export class AlbumService extends BaseService {
|
||||
);
|
||||
|
||||
const removedIds = results.filter(({ success }) => success).map(({ id }) => id);
|
||||
if (removedIds.length > 0 && album.albumThumbnailAssetId && removedIds.includes(album.albumThumbnailAssetId)) {
|
||||
await this.albumRepository.updateThumbnails();
|
||||
if (removedIds.length > 0) {
|
||||
if (album.albumThumbnailAssetId && removedIds.includes(album.albumThumbnailAssetId)) {
|
||||
await this.albumRepository.updateThumbnails();
|
||||
}
|
||||
|
||||
await this.eventRepository.emit('AlbumUpdate', {
|
||||
id,
|
||||
userIds: album.albumUsers.map(({ user }) => user.id),
|
||||
recipientIds: [],
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
|
||||
@@ -2,7 +2,6 @@ import { defaults, SystemConfig } from 'src/config';
|
||||
import { SystemConfigDto } from 'src/dtos/system-config.dto';
|
||||
import { AssetFileType, JobName, JobStatus, UserMetadataKey } from 'src/enum';
|
||||
import { NotificationService } from 'src/services/notification.service';
|
||||
import { INotifyAlbumUpdateJob } from 'src/types';
|
||||
import { AlbumFactory } from 'test/factories/album.factory';
|
||||
import { AssetFileFactory } from 'test/factories/asset-file.factory';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
@@ -157,13 +156,21 @@ describe(NotificationService.name, () => {
|
||||
});
|
||||
|
||||
describe('onAlbumUpdateEvent', () => {
|
||||
it('should queue notify album update event', async () => {
|
||||
await sut.onAlbumUpdate({ id: 'album', recipientId: '42' });
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
it('should send a websocket event to every user and queue notify jobs for recipients', async () => {
|
||||
await sut.onAlbumUpdate({ id: 'album', userIds: ['1', '42'], recipientIds: ['42'] });
|
||||
expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_album_update', '1', 'album');
|
||||
expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_album_update', '42', 'album');
|
||||
expect(mocks.job.queue).toHaveBeenCalledExactlyOnceWith({
|
||||
name: JobName.NotifyAlbumUpdate,
|
||||
data: { id: 'album', recipientId: '42', delay: 300_000 },
|
||||
});
|
||||
});
|
||||
|
||||
it('should not queue email jobs when there are no recipients', async () => {
|
||||
await sut.onAlbumUpdate({ id: 'album', userIds: ['1'], recipientIds: [] });
|
||||
expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_album_update', '1', 'album');
|
||||
expect(mocks.job.queue).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onAlbumInviteEvent', () => {
|
||||
@@ -522,7 +529,7 @@ describe(NotificationService.name, () => {
|
||||
});
|
||||
|
||||
it('should add new recipients for new images if job is already queued', async () => {
|
||||
await sut.onAlbumUpdate({ id: '1', recipientId: '2' } as INotifyAlbumUpdateJob);
|
||||
await sut.onAlbumUpdate({ id: '1', userIds: ['2'], recipientIds: ['2'] });
|
||||
expect(mocks.job.removeJob).toHaveBeenCalledWith(JobName.NotifyAlbumUpdate, '1/2');
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
name: JobName.NotifyAlbumUpdate,
|
||||
|
||||
@@ -217,12 +217,18 @@ export class NotificationService extends BaseService {
|
||||
}
|
||||
|
||||
@OnEvent({ name: 'AlbumUpdate' })
|
||||
async onAlbumUpdate({ id, recipientId }: ArgOf<'AlbumUpdate'>) {
|
||||
await this.jobRepository.removeJob(JobName.NotifyAlbumUpdate, `${id}/${recipientId}`);
|
||||
await this.jobRepository.queue({
|
||||
name: JobName.NotifyAlbumUpdate,
|
||||
data: { id, recipientId, delay: NotificationService.albumUpdateEmailDelayMs },
|
||||
});
|
||||
async onAlbumUpdate({ id, userIds, recipientIds }: ArgOf<'AlbumUpdate'>) {
|
||||
for (const userId of userIds) {
|
||||
this.websocketRepository.clientSend('on_album_update', userId, id);
|
||||
}
|
||||
|
||||
for (const recipientId of recipientIds) {
|
||||
await this.jobRepository.removeJob(JobName.NotifyAlbumUpdate, `${id}/${recipientId}`);
|
||||
await this.jobRepository.queue({
|
||||
name: JobName.NotifyAlbumUpdate,
|
||||
data: { id, recipientId, delay: NotificationService.albumUpdateEmailDelayMs },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@OnEvent({ name: 'AlbumInvite' })
|
||||
|
||||
@@ -9,6 +9,7 @@ import { AssetRepository } from 'src/repositories/asset.repository';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { CryptoRepository } from 'src/repositories/crypto.repository';
|
||||
import { DatabaseRepository } from 'src/repositories/database.repository';
|
||||
import { EventRepository } from 'src/repositories/event.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { PluginRepository } from 'src/repositories/plugin.repository';
|
||||
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||
@@ -39,7 +40,7 @@ class WorkflowTestContext extends MediumTestContext<WorkflowExecutionService> {
|
||||
UserRepository,
|
||||
WorkflowRepository,
|
||||
],
|
||||
mock: [ConfigRepository],
|
||||
mock: [ConfigRepository, EventRepository],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -52,6 +53,7 @@ class WorkflowTestContext extends MediumTestContext<WorkflowExecutionService> {
|
||||
mockData.resourcePaths.corePlugin = '../packages/plugin-core';
|
||||
mockData.plugins.external.allow = false;
|
||||
this.getMock(ConfigRepository).getEnv.mockReturnValue(mockData);
|
||||
this.getMock(EventRepository).emit.mockResolvedValue();
|
||||
this.get(LoggingRepository).setLogLevel(LogLevel.Verbose);
|
||||
|
||||
await this.sut.onPluginSync();
|
||||
@@ -431,9 +433,10 @@ describe('core plugin', () => {
|
||||
describe('assetDateFilter', () => {
|
||||
it('should favorite assets created during the first 7 days of a specific year and month', async () => {
|
||||
const { user } = await ctx.newUser();
|
||||
const [{ asset: asset1 }, { asset: asset2 }] = await Promise.all([
|
||||
const [{ asset: asset1 }, { asset: asset2 }, { asset: asset3 }] = await Promise.all([
|
||||
ctx.newAsset({ ownerId: user.id, localDateTime: new Date('2000-04-01') }),
|
||||
ctx.newAsset({ ownerId: user.id, localDateTime: new Date('2000-04-07T23:59:59Z') }),
|
||||
ctx.newAsset({ ownerId: user.id, localDateTime: new Date('2000-04-08T00:00:00Z') }),
|
||||
]);
|
||||
|
||||
const workflow = await createWorkflow({
|
||||
@@ -459,6 +462,46 @@ describe('core plugin', () => {
|
||||
|
||||
await ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset2.id });
|
||||
await expect(ctx.get(AssetRepository).getById(asset2.id)).resolves.toMatchObject({ isFavorite: true });
|
||||
|
||||
await ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset3.id });
|
||||
await expect(ctx.get(AssetRepository).getById(asset3.id)).resolves.toMatchObject({ isFavorite: false });
|
||||
});
|
||||
|
||||
it('should match recurring dates regardless of the year', async () => {
|
||||
const { user } = await ctx.newUser();
|
||||
const [{ asset: asset1 }, { asset: asset2 }, { asset: asset3 }] = await Promise.all([
|
||||
ctx.newAsset({ ownerId: user.id, localDateTime: new Date('2026-03-01') }),
|
||||
ctx.newAsset({ ownerId: user.id, localDateTime: new Date('1998-12-21') }),
|
||||
ctx.newAsset({ ownerId: user.id, localDateTime: new Date('2000-04-08T00:00:00Z') }),
|
||||
]);
|
||||
await ctx.newAsset({ ownerId: user.id, localDateTime: new Date('2010-06-15') });
|
||||
|
||||
const workflow = await createWorkflow({
|
||||
ownerId: user.id,
|
||||
trigger: WorkflowTrigger.AssetCreate,
|
||||
steps: [
|
||||
{
|
||||
method: 'immich-plugin-core#assetDateFilter',
|
||||
config: {
|
||||
startDate: { day: 12, month: 12, year: 2000 },
|
||||
endDate: { day: 30, month: 3, year: 2001 },
|
||||
recurring: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'immich-plugin-core#assetFavorite',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset1.id });
|
||||
await expect(ctx.get(AssetRepository).getById(asset1.id)).resolves.toMatchObject({ isFavorite: true });
|
||||
|
||||
await ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset2.id });
|
||||
await expect(ctx.get(AssetRepository).getById(asset2.id)).resolves.toMatchObject({ isFavorite: true });
|
||||
|
||||
await ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset3.id });
|
||||
await expect(ctx.get(AssetRepository).getById(asset3.id)).resolves.toMatchObject({ isFavorite: false });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -176,7 +176,7 @@
|
||||
|
||||
{#if showControls}
|
||||
<div
|
||||
class="dark m-4 flex gap-2"
|
||||
class="dark m-4 flex gap-2 rounded-3xl bg-black/40 px-2 backdrop-blur-sm"
|
||||
onmouseenter={() => (isOverControls = true)}
|
||||
onmouseleave={() => (isOverControls = false)}
|
||||
transition:fly={{ duration: 150 }}
|
||||
|
||||
Reference in New Issue
Block a user