diff --git a/e2e/src/ui/generators/timeline/model-objects.ts b/e2e/src/ui/generators/timeline/model-objects.ts index e300de1161..f5654afd5e 100644 --- a/e2e/src/ui/generators/timeline/model-objects.ts +++ b/e2e/src/ui/generators/timeline/model-objects.ts @@ -32,8 +32,12 @@ export function generateThumbhash(rng: SeededRandom): string { return Array.from({ length: 10 }, () => rng.nextInt(0, 256).toString(16).padStart(2, '0')).join(''); } -export function generateDuration(rng: SeededRandom): string { - return `${rng.nextInt(GENERATION_CONSTANTS.MIN_VIDEO_DURATION_SECONDS, GENERATION_CONSTANTS.MAX_VIDEO_DURATION_SECONDS)}.${rng.nextInt(0, 1000).toString().padStart(3, '0')}`; +export function generateDuration(rng: SeededRandom): number { + return ( + rng.nextInt(GENERATION_CONSTANTS.MIN_VIDEO_DURATION_SECONDS, GENERATION_CONSTANTS.MAX_VIDEO_DURATION_SECONDS) * + 1000 + + rng.nextInt(0, 1000) + ); } export function generateUUID(): string { diff --git a/e2e/src/ui/generators/timeline/timeline-config.ts b/e2e/src/ui/generators/timeline/timeline-config.ts index 992480eef9..4dea2f4f78 100644 --- a/e2e/src/ui/generators/timeline/timeline-config.ts +++ b/e2e/src/ui/generators/timeline/timeline-config.ts @@ -43,7 +43,7 @@ export type MockTimelineAsset = { isTrashed: boolean; isVideo: boolean; isImage: boolean; - duration: string | null; + duration: number | null; projectionType: string | null; livePhotoVideoId: string | null; city: string | null; diff --git a/mobile/lib/domain/services/sync_stream.service.dart b/mobile/lib/domain/services/sync_stream.service.dart index 01772683f7..906e352b49 100644 --- a/mobile/lib/domain/services/sync_stream.service.dart +++ b/mobile/lib/domain/services/sync_stream.service.dart @@ -192,23 +192,23 @@ class SyncStreamService { case SyncEntityType.assetV1: final remoteSyncAssets = data.cast(); await _syncStreamRepository.updateAssetsV1(remoteSyncAssets); - await _runWithManageMediaPermission( - logContext: "Trashed Assets", - action: () async { - await _handleRemoteDeleted(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.id)); - await _applyRemoteRestoreToLocal(); - }, - ); + if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) { + await _syncAssetTrashStatus(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.id).toList()); + } + return; + case SyncEntityType.assetV2: + final remoteSyncAssets = data.cast(); + await _syncStreamRepository.updateAssetsV2(remoteSyncAssets); + if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) { + await _syncAssetTrashStatus(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.id).toList()); + } return; case SyncEntityType.assetDeleteV1: - await _runWithManageMediaPermission( - logContext: "Deleted Assets", - action: () async { - final remoteSyncAssets = data.cast(); - await _handleRemoteDeleted(remoteSyncAssets.map((e) => e.assetId)); - }, - ); - return _syncStreamRepository.deleteAssetsV1(data.cast()); + final remoteSyncAssets = data.cast(); + if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) { + await _syncAssetDeletion(remoteSyncAssets.map((e) => e.assetId).toList()); + } + return _syncStreamRepository.deleteAssetsV1(remoteSyncAssets); case SyncEntityType.assetExifV1: return _syncStreamRepository.updateAssetsExifV1(data.cast()); case SyncEntityType.assetEditV1: @@ -221,8 +221,12 @@ class SyncStreamService { return _syncStreamRepository.deleteAssetsMetadataV1(data.cast()); case SyncEntityType.partnerAssetV1: return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'partner'); + case SyncEntityType.partnerAssetV2: + return _syncStreamRepository.updateAssetsV2(data.cast(), debugLabel: 'partner'); case SyncEntityType.partnerAssetBackfillV1: return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'partner backfill'); + case SyncEntityType.partnerAssetBackfillV2: + return _syncStreamRepository.updateAssetsV2(data.cast(), debugLabel: 'partner backfill'); case SyncEntityType.partnerAssetDeleteV1: return _syncStreamRepository.deleteAssetsV1(data.cast(), debugLabel: "partner"); case SyncEntityType.partnerAssetExifV1: @@ -243,10 +247,16 @@ class SyncStreamService { return _syncStreamRepository.deleteAlbumUsersV1(data.cast()); case SyncEntityType.albumAssetCreateV1: return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'album asset create'); + case SyncEntityType.albumAssetCreateV2: + return _syncStreamRepository.updateAssetsV2(data.cast(), debugLabel: 'album asset create'); case SyncEntityType.albumAssetUpdateV1: return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'album asset update'); + case SyncEntityType.albumAssetUpdateV2: + return _syncStreamRepository.updateAssetsV2(data.cast(), debugLabel: 'album asset update'); case SyncEntityType.albumAssetBackfillV1: return _syncStreamRepository.updateAssetsV1(data.cast(), debugLabel: 'album asset backfill'); + case SyncEntityType.albumAssetBackfillV2: + return _syncStreamRepository.updateAssetsV2(data.cast(), debugLabel: 'album asset backfill'); case SyncEntityType.albumAssetExifCreateV1: return _syncStreamRepository.updateAssetsExifV1(data.cast(), debugLabel: 'album asset exif create'); case SyncEntityType.albumAssetExifUpdateV1: @@ -348,6 +358,47 @@ class SyncStreamService { } } + Future handleWsAssetUploadReadyV2Batch(List batchData) async { + if (batchData.isEmpty) return; + + _logger.info('Processing batch of ${batchData.length} AssetUploadReadyV2 events'); + + final List assets = []; + final List exifs = []; + + try { + for (final data in batchData) { + if (data is! Map) { + continue; + } + + final payload = data; + final assetData = payload['asset']; + final exifData = payload['exif']; + + if (assetData == null || exifData == null) { + continue; + } + + final asset = SyncAssetV2.fromJson(assetData); + final exif = SyncAssetExifV1.fromJson(exifData); + + if (asset != null && exif != null) { + assets.add(asset); + exifs.add(exif); + } + } + + if (assets.isNotEmpty && exifs.isNotEmpty) { + await _syncStreamRepository.updateAssetsV2(assets, debugLabel: 'websocket-batch'); + await _syncStreamRepository.updateAssetsExifV1(exifs, debugLabel: 'websocket-batch'); + _logger.info('Successfully processed ${assets.length} assets in batch'); + } + } catch (error, stackTrace) { + _logger.severe("Error processing AssetUploadReadyV2 websocket batch events", error, stackTrace); + } + } + Future handleWsAssetEditReadyV1(dynamic data) async { _logger.info('Processing AssetEditReadyV1 event'); @@ -388,6 +439,41 @@ class SyncStreamService { } } + Future handleWsAssetEditReadyV2(dynamic data) async { + _logger.info('Processing AssetEditReadyV2 event'); + + try { + if (data is! Map) { + throw ArgumentError("Invalid data format for AssetEditReadyV2 event"); + } + + final payload = data; + + if (payload['asset'] == null) { + throw ArgumentError("Missing 'asset' field in AssetEditReadyV2 event data"); + } + + final asset = SyncAssetV2.fromJson(payload['asset']); + if (asset == null) { + throw ArgumentError("Failed to parse 'asset' field in AssetEditReadyV2 event data"); + } + + final assetEdits = (payload['edit'] as List) + .map((e) => SyncAssetEditV1.fromJson(e)) + .whereType() + .toList(); + + await _syncStreamRepository.updateAssetsV2([asset], debugLabel: 'websocket-edit'); + await _syncStreamRepository.replaceAssetEditsV1(asset.id, assetEdits, debugLabel: 'websocket-edit'); + + _logger.info( + 'Successfully processed AssetEditReadyV2 event for asset ${asset.id} with ${assetEdits.length} edits', + ); + } catch (error, stackTrace) { + _logger.severe("Error processing AssetEditReadyV2 websocket event", error, stackTrace); + } + } + Future _handleRemoteDeleted(Iterable remoteIds) async { if (remoteIds.isEmpty) { return Future.value(); @@ -424,20 +510,22 @@ class SyncStreamService { } } - Future _runWithManageMediaPermission({ - required String logContext, - required Future Function() action, - }) async { - if (!CurrentPlatform.isAndroid || !Store.get(StoreKey.manageLocalMediaAndroid, false)) { + Future _syncAssetTrashStatus(List remoteIds) async { + if (!(await _localFilesManager.hasManageMediaPermission())) { + _logger.warning("Syncing asset trash status cannot proceed because MANAGE_MEDIA permission is missing"); return; } - final hasPermission = await _localFilesManager.hasManageMediaPermission(); - if (!hasPermission) { - _logger.warning("sync $logContext cannot proceed because MANAGE_MEDIA permission is missing"); + await _handleRemoteDeleted(remoteIds); + await _applyRemoteRestoreToLocal(); + } + + Future _syncAssetDeletion(List remoteIds) async { + if (!(await _localFilesManager.hasManageMediaPermission())) { + _logger.warning("Syncing asset deletion cannot proceed because MANAGE_MEDIA permission is missing"); return; } - await action(); + await _handleRemoteDeleted(remoteIds); } } diff --git a/mobile/lib/domain/utils/background_sync.dart b/mobile/lib/domain/utils/background_sync.dart index 7c9b6ae061..030e77cd54 100644 --- a/mobile/lib/domain/utils/background_sync.dart +++ b/mobile/lib/domain/utils/background_sync.dart @@ -186,7 +186,7 @@ class BackgroundSyncManager { }); } - Future syncWebsocketBatch(List batchData) { + Future syncWebsocketBatchV1(List batchData) { if (_syncWebsocketTask != null) { return _syncWebsocketTask!.future; } @@ -196,7 +196,17 @@ class BackgroundSyncManager { }); } - Future syncWebsocketEdit(dynamic data) { + Future syncWebsocketBatchV2(List batchData) { + if (_syncWebsocketTask != null) { + return _syncWebsocketTask!.future; + } + _syncWebsocketTask = _handleWsAssetUploadReadyV2Batch(batchData); + return _syncWebsocketTask!.whenComplete(() { + _syncWebsocketTask = null; + }); + } + + Future syncWebsocketEditV1(dynamic data) { if (_syncWebsocketTask != null) { return _syncWebsocketTask!.future; } @@ -206,6 +216,16 @@ class BackgroundSyncManager { }); } + Future syncWebsocketEditV2(dynamic data) { + if (_syncWebsocketTask != null) { + return _syncWebsocketTask!.future; + } + _syncWebsocketTask = _handleWsAssetEditReadyV2(data); + return _syncWebsocketTask!.whenComplete(() { + _syncWebsocketTask = null; + }); + } + Future syncLinkedAlbum() { if (_linkedAlbumSyncTask != null) { return _linkedAlbumSyncTask!.future; @@ -242,7 +262,17 @@ Cancelable _handleWsAssetUploadReadyV1Batch(List batchData) => ru debugLabel: 'websocket-batch', ); +Cancelable _handleWsAssetUploadReadyV2Batch(List batchData) => runInIsolateGentle( + computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetUploadReadyV2Batch(batchData), + debugLabel: 'websocket-batch', +); + Cancelable _handleWsAssetEditReadyV1(dynamic data) => runInIsolateGentle( computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetEditReadyV1(data), debugLabel: 'websocket-edit', ); + +Cancelable _handleWsAssetEditReadyV2(dynamic data) => runInIsolateGentle( + computation: (ref) => ref.read(syncStreamServiceProvider).handleWsAssetEditReadyV2(data), + debugLabel: 'websocket-edit', +); diff --git a/mobile/lib/extensions/asset_extensions.dart b/mobile/lib/extensions/asset_extensions.dart index 6bcc11f18d..3a994f9cb8 100644 --- a/mobile/lib/extensions/asset_extensions.dart +++ b/mobile/lib/extensions/asset_extensions.dart @@ -1,6 +1,5 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/exif.model.dart'; -import 'package:immich_mobile/extensions/string_extensions.dart'; import 'package:immich_mobile/infrastructure/utils/exif.converter.dart'; import 'package:openapi/api.dart' as api; @@ -14,7 +13,7 @@ extension DTOToAsset on api.AssetResponseDto { updatedAt: updatedAt, ownerId: ownerId, visibility: visibility.toAssetVisibility(), - durationMs: duration?.toDuration()?.inMilliseconds ?? 0, + durationMs: duration, height: height?.toInt(), width: width?.toInt(), isFavorite: isFavorite, @@ -36,7 +35,7 @@ extension DTOToAsset on api.AssetResponseDto { updatedAt: updatedAt, ownerId: ownerId, visibility: visibility.toAssetVisibility(), - durationMs: duration?.toDuration()?.inMilliseconds ?? 0, + durationMs: duration, height: height?.toInt(), width: width?.toInt(), isFavorite: isFavorite, diff --git a/mobile/lib/infrastructure/repositories/sync_api.repository.dart b/mobile/lib/infrastructure/repositories/sync_api.repository.dart index 83a0d6d38f..366803ee31 100644 --- a/mobile/lib/infrastructure/repositories/sync_api.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_api.repository.dart @@ -46,19 +46,25 @@ class SyncApiRepository { types: [ SyncRequestType.authUsersV1, SyncRequestType.usersV1, - SyncRequestType.assetsV1, + serverVersion >= const SemVer(major: 3, minor: 0, patch: 0) + ? SyncRequestType.assetsV2 + : SyncRequestType.assetsV1, SyncRequestType.assetExifsV1, if (serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)) SyncRequestType.assetEditsV1, SyncRequestType.assetMetadataV1, SyncRequestType.partnersV1, - SyncRequestType.partnerAssetsV1, + serverVersion >= const SemVer(major: 3, minor: 0, patch: 0) + ? SyncRequestType.partnerAssetsV2 + : SyncRequestType.partnerAssetsV1, SyncRequestType.partnerAssetExifsV1, if (serverVersion < const SemVer(major: 3, minor: 0, patch: 0)) SyncRequestType.albumsV1 else SyncRequestType.albumsV2, SyncRequestType.albumUsersV1, - SyncRequestType.albumAssetsV1, + serverVersion >= const SemVer(major: 3, minor: 0, patch: 0) + ? SyncRequestType.albumAssetsV2 + : SyncRequestType.albumAssetsV1, SyncRequestType.albumAssetExifsV1, SyncRequestType.albumToAssetsV1, SyncRequestType.memoriesV1, @@ -67,8 +73,9 @@ class SyncApiRepository { SyncRequestType.partnerStacksV1, SyncRequestType.userMetadataV1, SyncRequestType.peopleV1, - if (serverVersion < const SemVer(major: 2, minor: 6, patch: 0)) SyncRequestType.assetFacesV1, - if (serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)) SyncRequestType.assetFacesV2, + serverVersion >= const SemVer(major: 2, minor: 6, patch: 0) + ? SyncRequestType.assetFacesV2 + : SyncRequestType.assetFacesV1, ], reset: shouldReset, ).toJson(), @@ -153,6 +160,7 @@ const _kResponseMap = { SyncEntityType.partnerV1: SyncPartnerV1.fromJson, SyncEntityType.partnerDeleteV1: SyncPartnerDeleteV1.fromJson, SyncEntityType.assetV1: SyncAssetV1.fromJson, + SyncEntityType.assetV2: SyncAssetV2.fromJson, SyncEntityType.assetDeleteV1: SyncAssetDeleteV1.fromJson, SyncEntityType.assetExifV1: SyncAssetExifV1.fromJson, SyncEntityType.assetEditV1: SyncAssetEditV1.fromJson, @@ -160,7 +168,9 @@ const _kResponseMap = { SyncEntityType.assetMetadataV1: SyncAssetMetadataV1.fromJson, SyncEntityType.assetMetadataDeleteV1: SyncAssetMetadataDeleteV1.fromJson, SyncEntityType.partnerAssetV1: SyncAssetV1.fromJson, + SyncEntityType.partnerAssetV2: SyncAssetV2.fromJson, SyncEntityType.partnerAssetBackfillV1: SyncAssetV1.fromJson, + SyncEntityType.partnerAssetBackfillV2: SyncAssetV2.fromJson, SyncEntityType.partnerAssetDeleteV1: SyncAssetDeleteV1.fromJson, SyncEntityType.partnerAssetExifV1: SyncAssetExifV1.fromJson, SyncEntityType.partnerAssetExifBackfillV1: SyncAssetExifV1.fromJson, @@ -171,8 +181,11 @@ const _kResponseMap = { SyncEntityType.albumUserBackfillV1: SyncAlbumUserV1.fromJson, SyncEntityType.albumUserDeleteV1: SyncAlbumUserDeleteV1.fromJson, SyncEntityType.albumAssetCreateV1: SyncAssetV1.fromJson, + SyncEntityType.albumAssetCreateV2: SyncAssetV2.fromJson, SyncEntityType.albumAssetUpdateV1: SyncAssetV1.fromJson, + SyncEntityType.albumAssetUpdateV2: SyncAssetV2.fromJson, SyncEntityType.albumAssetBackfillV1: SyncAssetV1.fromJson, + SyncEntityType.albumAssetBackfillV2: SyncAssetV2.fromJson, SyncEntityType.albumAssetExifCreateV1: SyncAssetExifV1.fromJson, SyncEntityType.albumAssetExifUpdateV1: SyncAssetExifV1.fromJson, SyncEntityType.albumAssetExifBackfillV1: SyncAssetExifV1.fromJson, diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart index 8079b00503..d9b9b3fa97 100644 --- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart @@ -220,6 +220,44 @@ class SyncStreamRepository extends DriftDatabaseRepository { } } + Future updateAssetsV2(Iterable data, {String debugLabel = 'user'}) async { + try { + await _db.batch((batch) { + for (final asset in data) { + final companion = RemoteAssetEntityCompanion( + name: Value(asset.originalFileName), + type: Value(asset.type.toAssetType()), + createdAt: Value.absentIfNull(asset.fileCreatedAt), + updatedAt: Value.absentIfNull(asset.fileModifiedAt), + durationMs: Value(asset.duration), + checksum: Value(asset.checksum), + isFavorite: Value(asset.isFavorite), + ownerId: Value(asset.ownerId), + localDateTime: Value(asset.localDateTime), + thumbHash: Value(asset.thumbhash), + deletedAt: Value(asset.deletedAt), + visibility: Value(asset.visibility.toAssetVisibility()), + livePhotoVideoId: Value(asset.livePhotoVideoId), + stackId: Value(asset.stackId), + libraryId: Value(asset.libraryId), + width: Value(asset.width), + height: Value(asset.height), + isEdited: Value(asset.isEdited), + ); + + batch.insert( + _db.remoteAssetEntity, + companion.copyWith(id: Value(asset.id)), + onConflict: DoUpdate((_) => companion), + ); + } + }); + } catch (error, stack) { + _logger.severe('Error: updateAssetsV2 - $debugLabel', error, stack); + rethrow; + } + } + Future updateAssetsExifV1(Iterable data, {String debugLabel = 'user'}) async { try { await _db.batch((batch) { diff --git a/mobile/lib/providers/websocket.provider.dart b/mobile/lib/providers/websocket.provider.dart index c79f40a25d..60afcec2d2 100644 --- a/mobile/lib/providers/websocket.provider.dart +++ b/mobile/lib/providers/websocket.provider.dart @@ -94,8 +94,10 @@ class WebsocketNotifier extends StateNotifier { state = const WebsocketState(isConnected: false, socket: null); }); - socket.on('AssetUploadReadyV1', _handleSyncAssetUploadReady); - socket.on('AssetEditReadyV1', _handleSyncAssetEditReady); + socket.on('AssetUploadReadyV1', _handleSyncAssetUploadReadyV1); + socket.on('AssetUploadReadyV2', _handleSyncAssetUploadReadyV2); + socket.on('AssetEditReadyV1', _handleSyncAssetEditReadyV1); + socket.on('AssetEditReadyV2', _handleSyncAssetEditReadyV2); socket.on('on_config_update', _handleOnConfigUpdate); socket.on('on_new_release', _handleReleaseUpdates); } catch (e) { @@ -163,16 +165,25 @@ class WebsocketNotifier extends StateNotifier { _ref.read(serverInfoProvider.notifier).handleReleaseInfo(serverVersion, releaseVersion); } - void _handleSyncAssetUploadReady(dynamic data) { + void _handleSyncAssetUploadReadyV1(dynamic data) { _batchedAssetUploadReady.add(data); - _batchDebouncer.run(_processBatchedAssetUploadReady); + _batchDebouncer.run(_processBatchedAssetUploadReadyV1); } - void _handleSyncAssetEditReady(dynamic data) { - unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEdit(data)); + void _handleSyncAssetUploadReadyV2(dynamic data) { + _batchedAssetUploadReady.add(data); + _batchDebouncer.run(_processBatchedAssetUploadReadyV2); } - void _processBatchedAssetUploadReady() { + void _handleSyncAssetEditReadyV1(dynamic data) { + unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditV1(data)); + } + + void _handleSyncAssetEditReadyV2(dynamic data) { + unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditV2(data)); + } + + void _processBatchedAssetUploadReadyV1() { if (_batchedAssetUploadReady.isEmpty) { return; } @@ -180,7 +191,7 @@ class WebsocketNotifier extends StateNotifier { final isSyncAlbumEnabled = Store.get(StoreKey.syncAlbums, false); try { unawaited( - _ref.read(backgroundSyncProvider).syncWebsocketBatch(_batchedAssetUploadReady.toList()).then((_) { + _ref.read(backgroundSyncProvider).syncWebsocketBatchV1(_batchedAssetUploadReady.toList()).then((_) { if (isSyncAlbumEnabled) { _ref.read(backgroundSyncProvider).syncLinkedAlbum(); } @@ -192,6 +203,27 @@ class WebsocketNotifier extends StateNotifier { _batchedAssetUploadReady.clear(); } + + void _processBatchedAssetUploadReadyV2() { + if (_batchedAssetUploadReady.isEmpty) { + return; + } + + final isSyncAlbumEnabled = Store.get(StoreKey.syncAlbums, false); + try { + unawaited( + _ref.read(backgroundSyncProvider).syncWebsocketBatchV2(_batchedAssetUploadReady.toList()).then((_) { + if (isSyncAlbumEnabled) { + _ref.read(backgroundSyncProvider).syncLinkedAlbum(); + } + }), + ); + } catch (error) { + _log.severe("Error processing batched AssetUploadReadyV2 events: $error"); + } + + _batchedAssetUploadReady.clear(); + } } final websocketProvider = StateNotifierProvider((ref) { diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index cf702f62ff..10a44d7df1 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -579,6 +579,7 @@ Class | Method | HTTP request | Description - [SyncAssetMetadataDeleteV1](doc//SyncAssetMetadataDeleteV1.md) - [SyncAssetMetadataV1](doc//SyncAssetMetadataV1.md) - [SyncAssetV1](doc//SyncAssetV1.md) + - [SyncAssetV2](doc//SyncAssetV2.md) - [SyncAuthUserV1](doc//SyncAuthUserV1.md) - [SyncEntityType](doc//SyncEntityType.md) - [SyncMemoryAssetDeleteV1](doc//SyncMemoryAssetDeleteV1.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 1e819595fc..fc554b4970 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -327,6 +327,7 @@ part 'model/sync_asset_face_v2.dart'; part 'model/sync_asset_metadata_delete_v1.dart'; part 'model/sync_asset_metadata_v1.dart'; part 'model/sync_asset_v1.dart'; +part 'model/sync_asset_v2.dart'; part 'model/sync_auth_user_v1.dart'; part 'model/sync_entity_type.dart'; part 'model/sync_memory_asset_delete_v1.dart'; diff --git a/mobile/openapi/lib/api/assets_api.dart b/mobile/openapi/lib/api/assets_api.dart index 5046376168..691c57cd3e 100644 --- a/mobile/openapi/lib/api/assets_api.dart +++ b/mobile/openapi/lib/api/assets_api.dart @@ -1234,8 +1234,8 @@ class AssetsApi { /// * [String] xImmichChecksum: /// sha1 checksum that can be used for duplicate detection before the file is uploaded /// - /// * [String] duration: - /// Duration (for videos) + /// * [int] duration: + /// Duration in milliseconds (for videos) /// /// * [String] filename: /// Filename @@ -1253,7 +1253,7 @@ class AssetsApi { /// Sidecar file data /// /// * [AssetVisibility] visibility: - Future uploadAssetWithHttpInfo(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, String? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List? metadata, MultipartFile? sidecarData, AssetVisibility? visibility, }) async { + Future uploadAssetWithHttpInfo(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List? metadata, MultipartFile? sidecarData, AssetVisibility? visibility, }) async { // ignore: prefer_const_declarations final apiPath = r'/assets'; @@ -1358,8 +1358,8 @@ class AssetsApi { /// * [String] xImmichChecksum: /// sha1 checksum that can be used for duplicate detection before the file is uploaded /// - /// * [String] duration: - /// Duration (for videos) + /// * [int] duration: + /// Duration in milliseconds (for videos) /// /// * [String] filename: /// Filename @@ -1377,7 +1377,7 @@ class AssetsApi { /// Sidecar file data /// /// * [AssetVisibility] visibility: - Future uploadAsset(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, String? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List? metadata, MultipartFile? sidecarData, AssetVisibility? visibility, }) async { + Future uploadAsset(MultipartFile assetData, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? xImmichChecksum, int? duration, String? filename, bool? isFavorite, String? livePhotoVideoId, List? metadata, MultipartFile? sidecarData, AssetVisibility? visibility, }) async { final response = await uploadAssetWithHttpInfo(assetData, fileCreatedAt, fileModifiedAt, key: key, slug: slug, xImmichChecksum: xImmichChecksum, duration: duration, filename: filename, isFavorite: isFavorite, livePhotoVideoId: livePhotoVideoId, metadata: metadata, sidecarData: sidecarData, visibility: visibility, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 5a4b7b75c7..bb006fdd65 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -700,6 +700,8 @@ class ApiClient { return SyncAssetMetadataV1.fromJson(value); case 'SyncAssetV1': return SyncAssetV1.fromJson(value); + case 'SyncAssetV2': + return SyncAssetV2.fromJson(value); case 'SyncAuthUserV1': return SyncAuthUserV1.fromJson(value); case 'SyncEntityType': diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index 17a51eb241..7284c44580 100644 --- a/mobile/openapi/lib/model/asset_response_dto.dart +++ b/mobile/openapi/lib/model/asset_response_dto.dart @@ -57,8 +57,11 @@ class AssetResponseDto { /// Duplicate group ID String? duplicateId; - /// Video/gif duration in hh:mm:ss.SSS format (null for static images) - String? duration; + /// Video/gif duration in milliseconds (null for static images) + /// + /// Minimum value: 0 + /// Maximum value: 2147483647 + int? duration; /// /// Please note: This property should have been non-nullable! Since the specification file @@ -343,7 +346,7 @@ class AssetResponseDto { checksum: mapValueOfType(json, r'checksum')!, createdAt: mapDateTime(json, r'createdAt', r'')!, duplicateId: mapValueOfType(json, r'duplicateId'), - duration: mapValueOfType(json, r'duration'), + duration: mapValueOfType(json, r'duration'), exifInfo: ExifResponseDto.fromJson(json[r'exifInfo']), fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r'')!, fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r'')!, diff --git a/mobile/openapi/lib/model/sync_asset_v2.dart b/mobile/openapi/lib/model/sync_asset_v2.dart new file mode 100644 index 0000000000..ebe75c59b2 --- /dev/null +++ b/mobile/openapi/lib/model/sync_asset_v2.dart @@ -0,0 +1,321 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SyncAssetV2 { + /// Returns a new [SyncAssetV2] instance. + SyncAssetV2({ + required this.checksum, + required this.deletedAt, + required this.duration, + required this.fileCreatedAt, + required this.fileModifiedAt, + required this.height, + required this.id, + required this.isEdited, + required this.isFavorite, + required this.libraryId, + required this.livePhotoVideoId, + required this.localDateTime, + required this.originalFileName, + required this.ownerId, + required this.stackId, + required this.thumbhash, + required this.type, + required this.visibility, + required this.width, + }); + + /// Checksum + String checksum; + + /// Deleted at + DateTime? deletedAt; + + /// Duration + /// + /// Minimum value: 0 + /// Maximum value: 2147483647 + int? duration; + + /// File created at + DateTime? fileCreatedAt; + + /// File modified at + DateTime? fileModifiedAt; + + /// Asset height + /// + /// Minimum value: -9007199254740991 + /// Maximum value: 9007199254740991 + int? height; + + /// Asset ID + String id; + + /// Is edited + bool isEdited; + + /// Is favorite + bool isFavorite; + + /// Library ID + String? libraryId; + + /// Live photo video ID + String? livePhotoVideoId; + + /// Local date time + DateTime? localDateTime; + + /// Original file name + String originalFileName; + + /// Owner ID + String ownerId; + + /// Stack ID + String? stackId; + + /// Thumbhash + String? thumbhash; + + AssetTypeEnum type; + + AssetVisibility visibility; + + /// Asset width + /// + /// Minimum value: -9007199254740991 + /// Maximum value: 9007199254740991 + int? width; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncAssetV2 && + other.checksum == checksum && + other.deletedAt == deletedAt && + other.duration == duration && + other.fileCreatedAt == fileCreatedAt && + other.fileModifiedAt == fileModifiedAt && + other.height == height && + other.id == id && + other.isEdited == isEdited && + other.isFavorite == isFavorite && + other.libraryId == libraryId && + other.livePhotoVideoId == livePhotoVideoId && + other.localDateTime == localDateTime && + other.originalFileName == originalFileName && + other.ownerId == ownerId && + other.stackId == stackId && + other.thumbhash == thumbhash && + other.type == type && + other.visibility == visibility && + other.width == width; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (checksum.hashCode) + + (deletedAt == null ? 0 : deletedAt!.hashCode) + + (duration == null ? 0 : duration!.hashCode) + + (fileCreatedAt == null ? 0 : fileCreatedAt!.hashCode) + + (fileModifiedAt == null ? 0 : fileModifiedAt!.hashCode) + + (height == null ? 0 : height!.hashCode) + + (id.hashCode) + + (isEdited.hashCode) + + (isFavorite.hashCode) + + (libraryId == null ? 0 : libraryId!.hashCode) + + (livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) + + (localDateTime == null ? 0 : localDateTime!.hashCode) + + (originalFileName.hashCode) + + (ownerId.hashCode) + + (stackId == null ? 0 : stackId!.hashCode) + + (thumbhash == null ? 0 : thumbhash!.hashCode) + + (type.hashCode) + + (visibility.hashCode) + + (width == null ? 0 : width!.hashCode); + + @override + String toString() => 'SyncAssetV2[checksum=$checksum, deletedAt=$deletedAt, duration=$duration, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, height=$height, id=$id, isEdited=$isEdited, isFavorite=$isFavorite, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, ownerId=$ownerId, stackId=$stackId, thumbhash=$thumbhash, type=$type, visibility=$visibility, width=$width]'; + + Map toJson() { + final json = {}; + json[r'checksum'] = this.checksum; + if (this.deletedAt != null) { + json[r'deletedAt'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/') + ? this.deletedAt!.millisecondsSinceEpoch + : this.deletedAt!.toUtc().toIso8601String(); + } else { + // json[r'deletedAt'] = null; + } + if (this.duration != null) { + json[r'duration'] = this.duration; + } else { + // json[r'duration'] = null; + } + if (this.fileCreatedAt != null) { + json[r'fileCreatedAt'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/') + ? this.fileCreatedAt!.millisecondsSinceEpoch + : this.fileCreatedAt!.toUtc().toIso8601String(); + } else { + // json[r'fileCreatedAt'] = null; + } + if (this.fileModifiedAt != null) { + json[r'fileModifiedAt'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/') + ? this.fileModifiedAt!.millisecondsSinceEpoch + : this.fileModifiedAt!.toUtc().toIso8601String(); + } else { + // json[r'fileModifiedAt'] = null; + } + if (this.height != null) { + json[r'height'] = this.height; + } else { + // json[r'height'] = null; + } + json[r'id'] = this.id; + json[r'isEdited'] = this.isEdited; + json[r'isFavorite'] = this.isFavorite; + if (this.libraryId != null) { + json[r'libraryId'] = this.libraryId; + } else { + // json[r'libraryId'] = null; + } + if (this.livePhotoVideoId != null) { + json[r'livePhotoVideoId'] = this.livePhotoVideoId; + } else { + // json[r'livePhotoVideoId'] = null; + } + if (this.localDateTime != null) { + json[r'localDateTime'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/') + ? this.localDateTime!.millisecondsSinceEpoch + : this.localDateTime!.toUtc().toIso8601String(); + } else { + // json[r'localDateTime'] = null; + } + json[r'originalFileName'] = this.originalFileName; + json[r'ownerId'] = this.ownerId; + if (this.stackId != null) { + json[r'stackId'] = this.stackId; + } else { + // json[r'stackId'] = null; + } + if (this.thumbhash != null) { + json[r'thumbhash'] = this.thumbhash; + } else { + // json[r'thumbhash'] = null; + } + json[r'type'] = this.type; + json[r'visibility'] = this.visibility; + if (this.width != null) { + json[r'width'] = this.width; + } else { + // json[r'width'] = null; + } + return json; + } + + /// Returns a new [SyncAssetV2] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncAssetV2? fromJson(dynamic value) { + upgradeDto(value, "SyncAssetV2"); + if (value is Map) { + final json = value.cast(); + + return SyncAssetV2( + checksum: mapValueOfType(json, r'checksum')!, + deletedAt: mapDateTime(json, r'deletedAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/'), + duration: mapValueOfType(json, r'duration'), + fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/'), + fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/'), + height: mapValueOfType(json, r'height'), + id: mapValueOfType(json, r'id')!, + isEdited: mapValueOfType(json, r'isEdited')!, + isFavorite: mapValueOfType(json, r'isFavorite')!, + libraryId: mapValueOfType(json, r'libraryId'), + livePhotoVideoId: mapValueOfType(json, r'livePhotoVideoId'), + localDateTime: mapDateTime(json, r'localDateTime', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/'), + originalFileName: mapValueOfType(json, r'originalFileName')!, + ownerId: mapValueOfType(json, r'ownerId')!, + stackId: mapValueOfType(json, r'stackId'), + thumbhash: mapValueOfType(json, r'thumbhash'), + type: AssetTypeEnum.fromJson(json[r'type'])!, + visibility: AssetVisibility.fromJson(json[r'visibility'])!, + width: mapValueOfType(json, r'width'), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SyncAssetV2.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SyncAssetV2.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncAssetV2-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SyncAssetV2.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'checksum', + 'deletedAt', + 'duration', + 'fileCreatedAt', + 'fileModifiedAt', + 'height', + 'id', + 'isEdited', + 'isFavorite', + 'libraryId', + 'livePhotoVideoId', + 'localDateTime', + 'originalFileName', + 'ownerId', + 'stackId', + 'thumbhash', + 'type', + 'visibility', + 'width', + }; +} + diff --git a/mobile/openapi/lib/model/sync_entity_type.dart b/mobile/openapi/lib/model/sync_entity_type.dart index a8cf011ee0..124cfdc8c4 100644 --- a/mobile/openapi/lib/model/sync_entity_type.dart +++ b/mobile/openapi/lib/model/sync_entity_type.dart @@ -27,6 +27,7 @@ class SyncEntityType { static const userV1 = SyncEntityType._(r'UserV1'); static const userDeleteV1 = SyncEntityType._(r'UserDeleteV1'); static const assetV1 = SyncEntityType._(r'AssetV1'); + static const assetV2 = SyncEntityType._(r'AssetV2'); static const assetDeleteV1 = SyncEntityType._(r'AssetDeleteV1'); static const assetExifV1 = SyncEntityType._(r'AssetExifV1'); static const assetEditV1 = SyncEntityType._(r'AssetEditV1'); @@ -36,7 +37,9 @@ class SyncEntityType { static const partnerV1 = SyncEntityType._(r'PartnerV1'); static const partnerDeleteV1 = SyncEntityType._(r'PartnerDeleteV1'); static const partnerAssetV1 = SyncEntityType._(r'PartnerAssetV1'); + static const partnerAssetV2 = SyncEntityType._(r'PartnerAssetV2'); static const partnerAssetBackfillV1 = SyncEntityType._(r'PartnerAssetBackfillV1'); + static const partnerAssetBackfillV2 = SyncEntityType._(r'PartnerAssetBackfillV2'); static const partnerAssetDeleteV1 = SyncEntityType._(r'PartnerAssetDeleteV1'); static const partnerAssetExifV1 = SyncEntityType._(r'PartnerAssetExifV1'); static const partnerAssetExifBackfillV1 = SyncEntityType._(r'PartnerAssetExifBackfillV1'); @@ -50,8 +53,11 @@ class SyncEntityType { static const albumUserBackfillV1 = SyncEntityType._(r'AlbumUserBackfillV1'); static const albumUserDeleteV1 = SyncEntityType._(r'AlbumUserDeleteV1'); static const albumAssetCreateV1 = SyncEntityType._(r'AlbumAssetCreateV1'); + static const albumAssetCreateV2 = SyncEntityType._(r'AlbumAssetCreateV2'); static const albumAssetUpdateV1 = SyncEntityType._(r'AlbumAssetUpdateV1'); + static const albumAssetUpdateV2 = SyncEntityType._(r'AlbumAssetUpdateV2'); static const albumAssetBackfillV1 = SyncEntityType._(r'AlbumAssetBackfillV1'); + static const albumAssetBackfillV2 = SyncEntityType._(r'AlbumAssetBackfillV2'); static const albumAssetExifCreateV1 = SyncEntityType._(r'AlbumAssetExifCreateV1'); static const albumAssetExifUpdateV1 = SyncEntityType._(r'AlbumAssetExifUpdateV1'); static const albumAssetExifBackfillV1 = SyncEntityType._(r'AlbumAssetExifBackfillV1'); @@ -81,6 +87,7 @@ class SyncEntityType { userV1, userDeleteV1, assetV1, + assetV2, assetDeleteV1, assetExifV1, assetEditV1, @@ -90,7 +97,9 @@ class SyncEntityType { partnerV1, partnerDeleteV1, partnerAssetV1, + partnerAssetV2, partnerAssetBackfillV1, + partnerAssetBackfillV2, partnerAssetDeleteV1, partnerAssetExifV1, partnerAssetExifBackfillV1, @@ -104,8 +113,11 @@ class SyncEntityType { albumUserBackfillV1, albumUserDeleteV1, albumAssetCreateV1, + albumAssetCreateV2, albumAssetUpdateV1, + albumAssetUpdateV2, albumAssetBackfillV1, + albumAssetBackfillV2, albumAssetExifCreateV1, albumAssetExifUpdateV1, albumAssetExifBackfillV1, @@ -170,6 +182,7 @@ class SyncEntityTypeTypeTransformer { case r'UserV1': return SyncEntityType.userV1; case r'UserDeleteV1': return SyncEntityType.userDeleteV1; case r'AssetV1': return SyncEntityType.assetV1; + case r'AssetV2': return SyncEntityType.assetV2; case r'AssetDeleteV1': return SyncEntityType.assetDeleteV1; case r'AssetExifV1': return SyncEntityType.assetExifV1; case r'AssetEditV1': return SyncEntityType.assetEditV1; @@ -179,7 +192,9 @@ class SyncEntityTypeTypeTransformer { case r'PartnerV1': return SyncEntityType.partnerV1; case r'PartnerDeleteV1': return SyncEntityType.partnerDeleteV1; case r'PartnerAssetV1': return SyncEntityType.partnerAssetV1; + case r'PartnerAssetV2': return SyncEntityType.partnerAssetV2; case r'PartnerAssetBackfillV1': return SyncEntityType.partnerAssetBackfillV1; + case r'PartnerAssetBackfillV2': return SyncEntityType.partnerAssetBackfillV2; case r'PartnerAssetDeleteV1': return SyncEntityType.partnerAssetDeleteV1; case r'PartnerAssetExifV1': return SyncEntityType.partnerAssetExifV1; case r'PartnerAssetExifBackfillV1': return SyncEntityType.partnerAssetExifBackfillV1; @@ -193,8 +208,11 @@ class SyncEntityTypeTypeTransformer { case r'AlbumUserBackfillV1': return SyncEntityType.albumUserBackfillV1; case r'AlbumUserDeleteV1': return SyncEntityType.albumUserDeleteV1; case r'AlbumAssetCreateV1': return SyncEntityType.albumAssetCreateV1; + case r'AlbumAssetCreateV2': return SyncEntityType.albumAssetCreateV2; case r'AlbumAssetUpdateV1': return SyncEntityType.albumAssetUpdateV1; + case r'AlbumAssetUpdateV2': return SyncEntityType.albumAssetUpdateV2; case r'AlbumAssetBackfillV1': return SyncEntityType.albumAssetBackfillV1; + case r'AlbumAssetBackfillV2': return SyncEntityType.albumAssetBackfillV2; case r'AlbumAssetExifCreateV1': return SyncEntityType.albumAssetExifCreateV1; case r'AlbumAssetExifUpdateV1': return SyncEntityType.albumAssetExifUpdateV1; case r'AlbumAssetExifBackfillV1': return SyncEntityType.albumAssetExifBackfillV1; diff --git a/mobile/openapi/lib/model/sync_request_type.dart b/mobile/openapi/lib/model/sync_request_type.dart index c50d5bb906..2c17cc6aef 100644 --- a/mobile/openapi/lib/model/sync_request_type.dart +++ b/mobile/openapi/lib/model/sync_request_type.dart @@ -28,8 +28,10 @@ class SyncRequestType { static const albumUsersV1 = SyncRequestType._(r'AlbumUsersV1'); static const albumToAssetsV1 = SyncRequestType._(r'AlbumToAssetsV1'); static const albumAssetsV1 = SyncRequestType._(r'AlbumAssetsV1'); + static const albumAssetsV2 = SyncRequestType._(r'AlbumAssetsV2'); static const albumAssetExifsV1 = SyncRequestType._(r'AlbumAssetExifsV1'); static const assetsV1 = SyncRequestType._(r'AssetsV1'); + static const assetsV2 = SyncRequestType._(r'AssetsV2'); static const assetExifsV1 = SyncRequestType._(r'AssetExifsV1'); static const assetEditsV1 = SyncRequestType._(r'AssetEditsV1'); static const assetMetadataV1 = SyncRequestType._(r'AssetMetadataV1'); @@ -38,6 +40,7 @@ class SyncRequestType { static const memoryToAssetsV1 = SyncRequestType._(r'MemoryToAssetsV1'); static const partnersV1 = SyncRequestType._(r'PartnersV1'); static const partnerAssetsV1 = SyncRequestType._(r'PartnerAssetsV1'); + static const partnerAssetsV2 = SyncRequestType._(r'PartnerAssetsV2'); static const partnerAssetExifsV1 = SyncRequestType._(r'PartnerAssetExifsV1'); static const partnerStacksV1 = SyncRequestType._(r'PartnerStacksV1'); static const stacksV1 = SyncRequestType._(r'StacksV1'); @@ -54,8 +57,10 @@ class SyncRequestType { albumUsersV1, albumToAssetsV1, albumAssetsV1, + albumAssetsV2, albumAssetExifsV1, assetsV1, + assetsV2, assetExifsV1, assetEditsV1, assetMetadataV1, @@ -64,6 +69,7 @@ class SyncRequestType { memoryToAssetsV1, partnersV1, partnerAssetsV1, + partnerAssetsV2, partnerAssetExifsV1, partnerStacksV1, stacksV1, @@ -115,8 +121,10 @@ class SyncRequestTypeTypeTransformer { case r'AlbumUsersV1': return SyncRequestType.albumUsersV1; case r'AlbumToAssetsV1': return SyncRequestType.albumToAssetsV1; case r'AlbumAssetsV1': return SyncRequestType.albumAssetsV1; + case r'AlbumAssetsV2': return SyncRequestType.albumAssetsV2; case r'AlbumAssetExifsV1': return SyncRequestType.albumAssetExifsV1; case r'AssetsV1': return SyncRequestType.assetsV1; + case r'AssetsV2': return SyncRequestType.assetsV2; case r'AssetExifsV1': return SyncRequestType.assetExifsV1; case r'AssetEditsV1': return SyncRequestType.assetEditsV1; case r'AssetMetadataV1': return SyncRequestType.assetMetadataV1; @@ -125,6 +133,7 @@ class SyncRequestTypeTypeTransformer { case r'MemoryToAssetsV1': return SyncRequestType.memoryToAssetsV1; case r'PartnersV1': return SyncRequestType.partnersV1; case r'PartnerAssetsV1': return SyncRequestType.partnerAssetsV1; + case r'PartnerAssetsV2': return SyncRequestType.partnerAssetsV2; case r'PartnerAssetExifsV1': return SyncRequestType.partnerAssetExifsV1; case r'PartnerStacksV1': return SyncRequestType.partnerStacksV1; case r'StacksV1': return SyncRequestType.stacksV1; diff --git a/mobile/openapi/lib/model/time_bucket_asset_response_dto.dart b/mobile/openapi/lib/model/time_bucket_asset_response_dto.dart index e2f9bec1ec..08e690de71 100644 --- a/mobile/openapi/lib/model/time_bucket_asset_response_dto.dart +++ b/mobile/openapi/lib/model/time_bucket_asset_response_dto.dart @@ -39,8 +39,8 @@ class TimeBucketAssetResponseDto { /// Array of country names extracted from EXIF GPS data List country; - /// Array of video/gif durations in hh:mm:ss.SSS format (null for static images) - List duration; + /// Array of video/gif durations in milliseconds (null for static images) + List duration; /// Array of file creation timestamps in UTC List fileCreatedAt; @@ -172,7 +172,7 @@ class TimeBucketAssetResponseDto { ? (json[r'country'] as Iterable).cast().toList(growable: false) : const [], duration: json[r'duration'] is Iterable - ? (json[r'duration'] as Iterable).cast().toList(growable: false) + ? (json[r'duration'] as Iterable).cast().toList(growable: false) : const [], fileCreatedAt: json[r'fileCreatedAt'] is Iterable ? (json[r'fileCreatedAt'] as Iterable).cast().toList(growable: false) diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 4d19fe90f6..8dfa1cb2d1 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -16261,8 +16261,10 @@ "type": "string" }, "duration": { - "description": "Duration (for videos)", - "type": "string" + "description": "Duration in milliseconds (for videos)", + "maximum": 2147483647, + "minimum": 0, + "type": "integer" }, "fileCreatedAt": { "description": "File creation date", @@ -16630,9 +16632,11 @@ "type": "string" }, "duration": { - "description": "Video/gif duration in hh:mm:ss.SSS format (null for static images)", + "description": "Video/gif duration in milliseconds (null for static images)", + "maximum": 2147483647, + "minimum": 0, "nullable": true, - "type": "string" + "type": "integer" }, "exifInfo": { "$ref": "#/components/schemas/ExifResponseDto" @@ -23175,6 +23179,135 @@ ], "type": "object" }, + "SyncAssetV2": { + "properties": { + "checksum": { + "description": "Checksum", + "type": "string" + }, + "deletedAt": { + "description": "Deleted at", + "example": "2024-01-01T00:00:00.000Z", + "format": "date-time", + "nullable": true, + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "type": "string" + }, + "duration": { + "description": "Duration", + "maximum": 2147483647, + "minimum": 0, + "nullable": true, + "type": "integer" + }, + "fileCreatedAt": { + "description": "File created at", + "example": "2024-01-01T00:00:00.000Z", + "format": "date-time", + "nullable": true, + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "type": "string" + }, + "fileModifiedAt": { + "description": "File modified at", + "example": "2024-01-01T00:00:00.000Z", + "format": "date-time", + "nullable": true, + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "type": "string" + }, + "height": { + "description": "Asset height", + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "nullable": true, + "type": "integer" + }, + "id": { + "description": "Asset ID", + "type": "string" + }, + "isEdited": { + "description": "Is edited", + "type": "boolean" + }, + "isFavorite": { + "description": "Is favorite", + "type": "boolean" + }, + "libraryId": { + "description": "Library ID", + "nullable": true, + "type": "string" + }, + "livePhotoVideoId": { + "description": "Live photo video ID", + "nullable": true, + "type": "string" + }, + "localDateTime": { + "description": "Local date time", + "example": "2024-01-01T00:00:00.000Z", + "format": "date-time", + "nullable": true, + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "type": "string" + }, + "originalFileName": { + "description": "Original file name", + "type": "string" + }, + "ownerId": { + "description": "Owner ID", + "type": "string" + }, + "stackId": { + "description": "Stack ID", + "nullable": true, + "type": "string" + }, + "thumbhash": { + "description": "Thumbhash", + "nullable": true, + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/AssetTypeEnum" + }, + "visibility": { + "$ref": "#/components/schemas/AssetVisibility" + }, + "width": { + "description": "Asset width", + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "nullable": true, + "type": "integer" + } + }, + "required": [ + "checksum", + "deletedAt", + "duration", + "fileCreatedAt", + "fileModifiedAt", + "height", + "id", + "isEdited", + "isFavorite", + "libraryId", + "livePhotoVideoId", + "localDateTime", + "originalFileName", + "ownerId", + "stackId", + "thumbhash", + "type", + "visibility", + "width" + ], + "type": "object" + }, "SyncAuthUserV1": { "properties": { "avatarColor": { @@ -23275,6 +23408,7 @@ "UserV1", "UserDeleteV1", "AssetV1", + "AssetV2", "AssetDeleteV1", "AssetExifV1", "AssetEditV1", @@ -23284,7 +23418,9 @@ "PartnerV1", "PartnerDeleteV1", "PartnerAssetV1", + "PartnerAssetV2", "PartnerAssetBackfillV1", + "PartnerAssetBackfillV2", "PartnerAssetDeleteV1", "PartnerAssetExifV1", "PartnerAssetExifBackfillV1", @@ -23298,8 +23434,11 @@ "AlbumUserBackfillV1", "AlbumUserDeleteV1", "AlbumAssetCreateV1", + "AlbumAssetCreateV2", "AlbumAssetUpdateV1", + "AlbumAssetUpdateV2", "AlbumAssetBackfillV1", + "AlbumAssetBackfillV2", "AlbumAssetExifCreateV1", "AlbumAssetExifUpdateV1", "AlbumAssetExifBackfillV1", @@ -23591,8 +23730,10 @@ "AlbumUsersV1", "AlbumToAssetsV1", "AlbumAssetsV1", + "AlbumAssetsV2", "AlbumAssetExifsV1", "AssetsV1", + "AssetsV2", "AssetExifsV1", "AssetEditsV1", "AssetMetadataV1", @@ -23601,6 +23742,7 @@ "MemoryToAssetsV1", "PartnersV1", "PartnerAssetsV1", + "PartnerAssetsV2", "PartnerAssetExifsV1", "PartnerStacksV1", "StacksV1", @@ -24994,10 +25136,12 @@ "type": "array" }, "duration": { - "description": "Array of video/gif durations in hh:mm:ss.SSS format (null for static images)", + "description": "Array of video/gif durations in milliseconds (null for static images)", "items": { + "maximum": 2147483647, + "minimum": 0, "nullable": true, - "type": "string" + "type": "integer" }, "type": "array" }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 40c4bec235..bc304e483e 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -614,8 +614,8 @@ export type AssetMetadataUpsertItemDto = { export type AssetMediaCreateDto = { /** Asset file data */ assetData: Blob; - /** Duration (for videos) */ - duration?: string; + /** Duration in milliseconds (for videos) */ + duration?: number; /** File creation date */ fileCreatedAt: string; /** File modification date */ @@ -854,8 +854,8 @@ export type AssetResponseDto = { createdAt: string; /** Duplicate group ID */ duplicateId?: string | null; - /** Video/gif duration in hh:mm:ss.SSS format (null for static images) */ - duration: string | null; + /** Video/gif duration in milliseconds (null for static images) */ + duration: number | null; exifInfo?: ExifResponseDto; /** The actual UTC timestamp when the file was created/captured, preserving timezone information. This is the authoritative timestamp for chronological sorting within timeline groups. Combined with timezone data, this can be used to determine the exact moment the photo was taken. */ fileCreatedAt: string; @@ -2673,8 +2673,8 @@ export type TimeBucketAssetResponseDto = { city: (string | null)[]; /** Array of country names extracted from EXIF GPS data */ country: (string | null)[]; - /** Array of video/gif durations in hh:mm:ss.SSS format (null for static images) */ - duration: (string | null)[]; + /** Array of video/gif durations in milliseconds (null for static images) */ + duration: (number | null)[]; /** Array of file creation timestamps in UTC */ fileCreatedAt: string[]; /** Array of asset IDs in the time bucket */ @@ -3075,6 +3075,44 @@ export type SyncAssetV1 = { /** Asset width */ width: number | null; }; +export type SyncAssetV2 = { + /** Checksum */ + checksum: string; + /** Deleted at */ + deletedAt: string | null; + /** Duration */ + duration: number | null; + /** File created at */ + fileCreatedAt: string | null; + /** File modified at */ + fileModifiedAt: string | null; + /** Asset height */ + height: number | null; + /** Asset ID */ + id: string; + /** Is edited */ + isEdited: boolean; + /** Is favorite */ + isFavorite: boolean; + /** Library ID */ + libraryId: string | null; + /** Live photo video ID */ + livePhotoVideoId: string | null; + /** Local date time */ + localDateTime: string | null; + /** Original file name */ + originalFileName: string; + /** Owner ID */ + ownerId: string; + /** Stack ID */ + stackId: string | null; + /** Thumbhash */ + thumbhash: string | null; + "type": AssetTypeEnum; + visibility: AssetVisibility; + /** Asset width */ + width: number | null; +}; export type SyncAuthUserV1 = { avatarColor?: (UserAvatarColor) | null; /** User deleted at */ @@ -7109,6 +7147,7 @@ export enum SyncEntityType { UserV1 = "UserV1", UserDeleteV1 = "UserDeleteV1", AssetV1 = "AssetV1", + AssetV2 = "AssetV2", AssetDeleteV1 = "AssetDeleteV1", AssetExifV1 = "AssetExifV1", AssetEditV1 = "AssetEditV1", @@ -7118,7 +7157,9 @@ export enum SyncEntityType { PartnerV1 = "PartnerV1", PartnerDeleteV1 = "PartnerDeleteV1", PartnerAssetV1 = "PartnerAssetV1", + PartnerAssetV2 = "PartnerAssetV2", PartnerAssetBackfillV1 = "PartnerAssetBackfillV1", + PartnerAssetBackfillV2 = "PartnerAssetBackfillV2", PartnerAssetDeleteV1 = "PartnerAssetDeleteV1", PartnerAssetExifV1 = "PartnerAssetExifV1", PartnerAssetExifBackfillV1 = "PartnerAssetExifBackfillV1", @@ -7132,8 +7173,11 @@ export enum SyncEntityType { AlbumUserBackfillV1 = "AlbumUserBackfillV1", AlbumUserDeleteV1 = "AlbumUserDeleteV1", AlbumAssetCreateV1 = "AlbumAssetCreateV1", + AlbumAssetCreateV2 = "AlbumAssetCreateV2", AlbumAssetUpdateV1 = "AlbumAssetUpdateV1", + AlbumAssetUpdateV2 = "AlbumAssetUpdateV2", AlbumAssetBackfillV1 = "AlbumAssetBackfillV1", + AlbumAssetBackfillV2 = "AlbumAssetBackfillV2", AlbumAssetExifCreateV1 = "AlbumAssetExifCreateV1", AlbumAssetExifUpdateV1 = "AlbumAssetExifUpdateV1", AlbumAssetExifBackfillV1 = "AlbumAssetExifBackfillV1", @@ -7163,8 +7207,10 @@ export enum SyncRequestType { AlbumUsersV1 = "AlbumUsersV1", AlbumToAssetsV1 = "AlbumToAssetsV1", AlbumAssetsV1 = "AlbumAssetsV1", + AlbumAssetsV2 = "AlbumAssetsV2", AlbumAssetExifsV1 = "AlbumAssetExifsV1", AssetsV1 = "AssetsV1", + AssetsV2 = "AssetsV2", AssetExifsV1 = "AssetExifsV1", AssetEditsV1 = "AssetEditsV1", AssetMetadataV1 = "AssetMetadataV1", @@ -7173,6 +7219,7 @@ export enum SyncRequestType { MemoryToAssetsV1 = "MemoryToAssetsV1", PartnersV1 = "PartnersV1", PartnerAssetsV1 = "PartnerAssetsV1", + PartnerAssetsV2 = "PartnerAssetsV2", PartnerAssetExifsV1 = "PartnerAssetExifsV1", PartnerStacksV1 = "PartnerStacksV1", StacksV1 = "StacksV1", diff --git a/server/src/dtos/asset-media.dto.ts b/server/src/dtos/asset-media.dto.ts index cd9c7de641..8515ecc0b3 100644 --- a/server/src/dtos/asset-media.dto.ts +++ b/server/src/dtos/asset-media.dto.ts @@ -38,7 +38,7 @@ export enum UploadFieldName { const AssetMediaBaseSchema = z.object({ fileCreatedAt: isoDatetimeToDate.describe('File creation date'), fileModifiedAt: isoDatetimeToDate.describe('File modification date'), - duration: z.string().optional().describe('Duration (for videos)'), + duration: z.int32().min(0).optional().describe('Duration in milliseconds (for videos)'), filename: z.string().optional().describe('Filename'), /** The properties below are added to correctly generate the API docs and client SDKs. Validation should be handled in the controller. */ [UploadFieldName.ASSET_DATA]: z.any().describe('Asset file data').meta({ type: 'string', format: 'binary' }), diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index ea0d9e66d0..99d1fe7e25 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -47,7 +47,7 @@ const SanitizedAssetResponseSchema = z .describe( 'The local date and time when the photo/video was taken, derived from EXIF metadata. This represents the photographer\'s local time regardless of timezone, stored as a timezone-agnostic timestamp. Used for timeline grouping by "local" days and months.', ), - duration: z.string().nullable().describe('Video/gif duration in hh:mm:ss.SSS format (null for static images)'), + duration: z.int32().min(0).nullable().describe('Video/gif duration in milliseconds (null for static images)'), livePhotoVideoId: z.string().nullish().describe('Live photo video ID'), hasMetadata: z.boolean().describe('Whether asset has metadata'), width: z.int().min(0).nullable().describe('Asset width'), @@ -136,7 +136,7 @@ export type MapAsset = { checksum: Buffer; checksumAlgorithm: ChecksumAlgorithm; duplicateId: string | null; - duration: string | null; + duration: number | null; edits?: ShallowDehydrateObject[]; exifInfo?: ShallowDehydrateObject> | null; faces?: ShallowDehydrateObject[]; diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index 0df617813d..c7333f6dea 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -90,6 +90,30 @@ const SyncAssetV1Schema = z }) .meta({ id: 'SyncAssetV1' }); +const SyncAssetV2Schema = z + .object({ + id: z.string().describe('Asset ID'), + ownerId: z.string().describe('Owner ID'), + originalFileName: z.string().describe('Original file name'), + thumbhash: z.string().nullable().describe('Thumbhash'), + checksum: z.string().describe('Checksum'), + fileCreatedAt: isoDatetimeToDate.nullable().describe('File created at'), + fileModifiedAt: isoDatetimeToDate.nullable().describe('File modified at'), + localDateTime: isoDatetimeToDate.nullable().describe('Local date time'), + duration: z.int32().min(0).nullable().describe('Duration'), + type: AssetTypeSchema, + deletedAt: isoDatetimeToDate.nullable().describe('Deleted at'), + isFavorite: z.boolean().describe('Is favorite'), + visibility: AssetVisibilitySchema, + livePhotoVideoId: z.string().nullable().describe('Live photo video ID'), + stackId: z.string().nullable().describe('Stack ID'), + libraryId: z.string().nullable().describe('Library ID'), + width: z.int().nullable().describe('Asset width'), + height: z.int().nullable().describe('Asset height'), + isEdited: z.boolean().describe('Is edited'), + }) + .meta({ id: 'SyncAssetV2' }); + @ExtraModel() class SyncUserV1 extends createZodDto(SyncUserV1Schema) {} @ExtraModel() @@ -102,6 +126,8 @@ class SyncPartnerV1 extends createZodDto(SyncPartnerV1Schema) {} class SyncPartnerDeleteV1 extends createZodDto(SyncPartnerDeleteV1Schema) {} @ExtraModel() export class SyncAssetV1 extends createZodDto(SyncAssetV1Schema) {} +@ExtraModel() +export class SyncAssetV2 extends createZodDto(SyncAssetV2Schema) {} const SyncAssetDeleteV1Schema = z .object({ assetId: z.string().describe('Asset ID') }) @@ -394,12 +420,6 @@ class SyncPersonDeleteV1 extends createZodDto(SyncPersonDeleteV1Schema) {} class SyncAssetFaceV1 extends createZodDto(SyncAssetFaceV1Schema) {} @ExtraModel() class SyncAssetFaceV2 extends createZodDto(SyncAssetFaceV2Schema) {} - -export function syncAssetFaceV2ToV1(faceV2: SyncAssetFaceV2): SyncAssetFaceV1 { - const { deletedAt: _, isVisible: __, ...faceV1 } = faceV2; - - return faceV1; -} @ExtraModel() class SyncAssetFaceDeleteV1 extends createZodDto(SyncAssetFaceDeleteV1Schema) {} @ExtraModel() @@ -419,15 +439,15 @@ export type SyncItem = { [SyncEntityType.UserDeleteV1]: SyncUserDeleteV1; [SyncEntityType.PartnerV1]: SyncPartnerV1; [SyncEntityType.PartnerDeleteV1]: SyncPartnerDeleteV1; - [SyncEntityType.AssetV1]: SyncAssetV1; + [SyncEntityType.AssetV2]: SyncAssetV2; [SyncEntityType.AssetDeleteV1]: SyncAssetDeleteV1; [SyncEntityType.AssetMetadataV1]: SyncAssetMetadataV1; [SyncEntityType.AssetMetadataDeleteV1]: SyncAssetMetadataDeleteV1; [SyncEntityType.AssetExifV1]: SyncAssetExifV1; [SyncEntityType.AssetEditV1]: SyncAssetEditV1; [SyncEntityType.AssetEditDeleteV1]: SyncAssetEditDeleteV1; - [SyncEntityType.PartnerAssetV1]: SyncAssetV1; - [SyncEntityType.PartnerAssetBackfillV1]: SyncAssetV1; + [SyncEntityType.PartnerAssetV2]: SyncAssetV2; + [SyncEntityType.PartnerAssetBackfillV2]: SyncAssetV2; [SyncEntityType.PartnerAssetDeleteV1]: SyncAssetDeleteV1; [SyncEntityType.PartnerAssetExifV1]: SyncAssetExifV1; [SyncEntityType.PartnerAssetExifBackfillV1]: SyncAssetExifV1; @@ -437,9 +457,9 @@ export type SyncItem = { [SyncEntityType.AlbumUserV1]: SyncAlbumUserV1; [SyncEntityType.AlbumUserBackfillV1]: SyncAlbumUserV1; [SyncEntityType.AlbumUserDeleteV1]: SyncAlbumUserDeleteV1; - [SyncEntityType.AlbumAssetCreateV1]: SyncAssetV1; - [SyncEntityType.AlbumAssetUpdateV1]: SyncAssetV1; - [SyncEntityType.AlbumAssetBackfillV1]: SyncAssetV1; + [SyncEntityType.AlbumAssetCreateV2]: SyncAssetV2; + [SyncEntityType.AlbumAssetUpdateV2]: SyncAssetV2; + [SyncEntityType.AlbumAssetBackfillV2]: SyncAssetV2; [SyncEntityType.AlbumAssetExifCreateV1]: SyncAssetExifV1; [SyncEntityType.AlbumAssetExifUpdateV1]: SyncAssetExifV1; [SyncEntityType.AlbumAssetExifBackfillV1]: SyncAssetExifV1; diff --git a/server/src/dtos/time-bucket.dto.ts b/server/src/dtos/time-bucket.dto.ts index 0b4be5cba1..88a352e20e 100644 --- a/server/src/dtos/time-bucket.dto.ts +++ b/server/src/dtos/time-bucket.dto.ts @@ -89,8 +89,8 @@ const TimeBucketAssetResponseSchema = z "Array of UTC offset hours at the time each photo was taken. Positive values are east of UTC, negative values are west of UTC. Values may be fractional (e.g., 5.5 for +05:30, -9.75 for -09:45). Applying this offset to 'fileCreatedAt' will give you the time the photo was taken from the photographer's perspective.", ), duration: z - .array(z.string().nullable()) - .describe('Array of video/gif durations in hh:mm:ss.SSS format (null for static images)'), + .array(z.int32().min(0).nullable()) + .describe('Array of video/gif durations in milliseconds (null for static images)'), stack: z .array(stackTupleSchema) .optional() diff --git a/server/src/enum.ts b/server/src/enum.ts index 778672424f..f7abb42c3a 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -806,9 +806,13 @@ export enum SyncRequestType { AlbumsV2 = 'AlbumsV2', AlbumUsersV1 = 'AlbumUsersV1', AlbumToAssetsV1 = 'AlbumToAssetsV1', + /** @deprecated */ AlbumAssetsV1 = 'AlbumAssetsV1', + AlbumAssetsV2 = 'AlbumAssetsV2', AlbumAssetExifsV1 = 'AlbumAssetExifsV1', + /** @deprecated */ AssetsV1 = 'AssetsV1', + AssetsV2 = 'AssetsV2', AssetExifsV1 = 'AssetExifsV1', AssetEditsV1 = 'AssetEditsV1', AssetMetadataV1 = 'AssetMetadataV1', @@ -816,12 +820,15 @@ export enum SyncRequestType { MemoriesV1 = 'MemoriesV1', MemoryToAssetsV1 = 'MemoryToAssetsV1', PartnersV1 = 'PartnersV1', + /** @deprecated */ PartnerAssetsV1 = 'PartnerAssetsV1', + PartnerAssetsV2 = 'PartnerAssetsV2', PartnerAssetExifsV1 = 'PartnerAssetExifsV1', PartnerStacksV1 = 'PartnerStacksV1', StacksV1 = 'StacksV1', UsersV1 = 'UsersV1', PeopleV1 = 'PeopleV1', + /** @deprecated */ AssetFacesV1 = 'AssetFacesV1', AssetFacesV2 = 'AssetFacesV2', UserMetadataV1 = 'UserMetadataV1', @@ -838,7 +845,9 @@ export enum SyncEntityType { UserV1 = 'UserV1', UserDeleteV1 = 'UserDeleteV1', + /** @deprecated */ AssetV1 = 'AssetV1', + AssetV2 = 'AssetV2', AssetDeleteV1 = 'AssetDeleteV1', AssetExifV1 = 'AssetExifV1', AssetEditV1 = 'AssetEditV1', @@ -849,8 +858,12 @@ export enum SyncEntityType { PartnerV1 = 'PartnerV1', PartnerDeleteV1 = 'PartnerDeleteV1', + /** @deprecated */ PartnerAssetV1 = 'PartnerAssetV1', + PartnerAssetV2 = 'PartnerAssetV2', + /** @deprecated */ PartnerAssetBackfillV1 = 'PartnerAssetBackfillV1', + PartnerAssetBackfillV2 = 'PartnerAssetBackfillV2', PartnerAssetDeleteV1 = 'PartnerAssetDeleteV1', PartnerAssetExifV1 = 'PartnerAssetExifV1', PartnerAssetExifBackfillV1 = 'PartnerAssetExifBackfillV1', @@ -866,9 +879,15 @@ export enum SyncEntityType { AlbumUserBackfillV1 = 'AlbumUserBackfillV1', AlbumUserDeleteV1 = 'AlbumUserDeleteV1', + /** @deprecated */ AlbumAssetCreateV1 = 'AlbumAssetCreateV1', + AlbumAssetCreateV2 = 'AlbumAssetCreateV2', + /** @deprecated */ AlbumAssetUpdateV1 = 'AlbumAssetUpdateV1', + AlbumAssetUpdateV2 = 'AlbumAssetUpdateV2', + /** @deprecated */ AlbumAssetBackfillV1 = 'AlbumAssetBackfillV1', + AlbumAssetBackfillV2 = 'AlbumAssetBackfillV2', AlbumAssetExifCreateV1 = 'AlbumAssetExifCreateV1', AlbumAssetExifUpdateV1 = 'AlbumAssetExifUpdateV1', AlbumAssetExifBackfillV1 = 'AlbumAssetExifBackfillV1', diff --git a/server/src/repositories/websocket.repository.ts b/server/src/repositories/websocket.repository.ts index 235d2f2a84..b4a0fcc00a 100644 --- a/server/src/repositories/websocket.repository.ts +++ b/server/src/repositories/websocket.repository.ts @@ -11,7 +11,7 @@ import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { NotificationDto } from 'src/dtos/notification.dto'; import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; -import { SyncAssetEditV1, SyncAssetExifV1, SyncAssetV1 } from 'src/dtos/sync.dto'; +import { SyncAssetEditV1, SyncAssetExifV1, SyncAssetV2 } from 'src/dtos/sync.dto'; import { AppRestartEvent, ArgsOf, EventRepository } from 'src/repositories/event.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { handlePromiseError } from 'src/utils/misc'; @@ -35,9 +35,9 @@ export interface ClientEventMap { on_notification: [NotificationDto]; on_session_delete: [string]; - AssetUploadReadyV1: [{ asset: SyncAssetV1; exif: SyncAssetExifV1 }]; + AssetUploadReadyV2: [{ asset: SyncAssetV2; exif: SyncAssetExifV1 }]; AppRestartV1: [AppRestartEvent]; - AssetEditReadyV1: [{ asset: SyncAssetV1; edit: SyncAssetEditV1[] }]; + AssetEditReadyV2: [{ asset: SyncAssetV2; edit: SyncAssetEditV1[] }]; } export type AuthFn = (client: Socket) => Promise; diff --git a/server/src/schema/migrations/1776735180298-ChangeDurationToInteger.ts b/server/src/schema/migrations/1776735180298-ChangeDurationToInteger.ts new file mode 100644 index 0000000000..61f7f06b06 --- /dev/null +++ b/server/src/schema/migrations/1776735180298-ChangeDurationToInteger.ts @@ -0,0 +1,31 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql` + ALTER TABLE asset + ALTER COLUMN duration TYPE integer + USING ( + CASE + WHEN duration ~ '^\\d{2}:\\d{2}:\\d{2}\\.\\d{3}$' + THEN substr(duration, 1, 2)::int * 3600000 + + substr(duration, 4, 2)::int * 60000 + + substr(duration, 7, 2)::int * 1000 + + substr(duration, 10, 3)::int + END + );`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql` + ALTER TABLE asset + ALTER COLUMN duration TYPE varchar + USING ( + CASE + WHEN duration IS NULL THEN NULL + ELSE lpad((duration / 3600000)::text, 2, '0') + || ':' || lpad(((duration / 60000) % 60)::text, 2, '0') + || ':' || lpad(((duration / 1000) % 60)::text, 2, '0') + || '.' || lpad((duration % 1000)::text, 3, '0') + END + );`.execute(db); +} diff --git a/server/src/schema/tables/asset.table.ts b/server/src/schema/tables/asset.table.ts index 718c19be5a..d4832648dd 100644 --- a/server/src/schema/tables/asset.table.ts +++ b/server/src/schema/tables/asset.table.ts @@ -83,8 +83,8 @@ export class AssetTable { @Column({ type: 'boolean', default: false }) isFavorite!: Generated; - @Column({ type: 'character varying', nullable: true }) - duration!: string | null; + @Column({ type: 'integer', nullable: true }) + duration!: number | null; @Column({ type: 'bytea', index: true }) checksum!: Buffer; // sha1 checksum diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index 98f369c31a..8a714615c9 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -101,7 +101,7 @@ export class JobService extends BaseService { const edits = await this.assetEditRepository.getWithSyncInfo(item.data.id); if (asset) { - this.websocketRepository.clientSend('AssetEditReadyV1', asset.ownerId, { + this.websocketRepository.clientSend('AssetEditReadyV2', asset.ownerId, { asset: { id: asset.id, ownerId: asset.ownerId, @@ -156,7 +156,7 @@ export class JobService extends BaseService { this.websocketRepository.clientSend('on_upload_success', asset.ownerId, mapAsset(asset)); if (asset.exifInfo) { const exif = asset.exifInfo; - this.websocketRepository.clientSend('AssetUploadReadyV1', asset.ownerId, { + this.websocketRepository.clientSend('AssetUploadReadyV2', asset.ownerId, { // TODO remove `on_upload_success` and then modify the query to select only the required fields) asset: { id: asset.id, diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 245bb441a6..b796094bb5 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -999,7 +999,7 @@ describe(MetadataService.name, () => { expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ id: asset.id, - duration: '00:00:06.210', + duration: 6210, }), ); }); @@ -1067,7 +1067,7 @@ describe(MetadataService.name, () => { expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ id: asset.id, - duration: '168:00:00.000', + duration: 604_800_000, }), ); }); @@ -1080,7 +1080,7 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.metadata.readTags).toHaveBeenCalledTimes(1); - expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: '00:02:03.000' })); + expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: 123_000 })); }); it('should prefer Duration from exif over sidecar', async () => { @@ -1092,7 +1092,7 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.metadata.readTags).toHaveBeenCalledTimes(2); - expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: '00:02:03.000' })); + expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: 123_000 })); }); it('should ignore all Duration tags for definitely static images', async () => { @@ -1121,7 +1121,7 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.metadata.readTags).toHaveBeenCalledTimes(1); - expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: '00:07:36.000' })); + expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: 456_000 })); }); it('should trim whitespace from description', async () => { diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index c548d94c74..168f7634fd 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -1001,18 +1001,10 @@ export class MetadataService extends BaseService { return bitsPerSample; } - private getDuration(tags: ImmichTags): string | null { + private getDuration(tags: ImmichTags): number | null { const duration = tags.Duration; - - if (typeof duration === 'string') { - return duration; - } - - if (typeof duration === 'number') { - return Duration.fromObject({ seconds: duration }).toFormat('hh:mm:ss.SSS'); - } - - return null; + const seconds = typeof duration === 'number' ? duration : Number.parseFloat(duration as string); + return Number.isFinite(seconds) ? Math.round(Duration.fromObject({ seconds }).toMillis()) : null; } private async getVideoTags(originalPath: string) { diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index d4d1a46b4a..68c127eb56 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -8,8 +8,7 @@ import { SyncAckDeleteDto, SyncAckSetDto, syncAlbumV2ToV1, - syncAssetFaceV2ToV1, - SyncAssetV1, + SyncAssetV2, SyncItem, SyncStreamDto, } from 'src/dtos/sync.dto'; @@ -22,7 +21,7 @@ import { hexOrBufferToBase64 } from 'src/utils/bytes'; import { fromAck, serialize, SerializeOptions, toAck } from 'src/utils/sync'; type CheckpointMap = Partial>; -type AssetLike = Omit & { +type AssetLike = Omit & { checksum: Buffer; thumbhash: Buffer | null; }; @@ -31,7 +30,7 @@ const COMPLETE_ID = 'complete'; const MAX_DAYS = 30; const MAX_DURATION = Duration.fromObject({ days: MAX_DAYS }); -const mapSyncAssetV1 = ({ checksum, thumbhash, ...data }: AssetLike): SyncAssetV1 => ({ +const mapSyncAssetV2 = ({ checksum, thumbhash, ...data }: AssetLike): SyncAssetV2 => ({ ...data, checksum: hexOrBufferToBase64(checksum), thumbhash: thumbhash ? hexOrBufferToBase64(thumbhash) : null, @@ -56,10 +55,13 @@ export const SYNC_TYPES_ORDER = [ SyncRequestType.UsersV1, SyncRequestType.PartnersV1, SyncRequestType.AssetsV1, + SyncRequestType.AssetsV2, SyncRequestType.StacksV1, SyncRequestType.PartnerAssetsV1, + SyncRequestType.PartnerAssetsV2, SyncRequestType.PartnerStacksV1, SyncRequestType.AlbumAssetsV1, + SyncRequestType.AlbumAssetsV2, SyncRequestType.AlbumsV1, SyncRequestType.AlbumsV2, SyncRequestType.AlbumUsersV1, @@ -156,20 +158,26 @@ export class SyncService extends BaseService { const options: SyncQueryOptions = { nowId, userId: auth.user.id }; const handlers: Record Promise> = { + // deprecated handlers + [SyncRequestType.AssetsV1]: () => this.syncAssetsV1(), + [SyncRequestType.AssetFacesV1]: () => this.syncAssetFacesV1(), + [SyncRequestType.PartnerAssetsV1]: () => this.syncPartnerAssetsV1(), + [SyncRequestType.AlbumAssetsV1]: () => this.syncAlbumAssetsV1(), + [SyncRequestType.AuthUsersV1]: () => this.syncAuthUsersV1(options, response, checkpointMap), [SyncRequestType.UsersV1]: () => this.syncUsersV1(options, response, checkpointMap), [SyncRequestType.PartnersV1]: () => this.syncPartnersV1(options, response, checkpointMap), - [SyncRequestType.AssetsV1]: () => this.syncAssetsV1(options, response, checkpointMap), + [SyncRequestType.AssetsV2]: () => this.syncAssetsV2(options, response, checkpointMap), [SyncRequestType.AssetExifsV1]: () => this.syncAssetExifsV1(options, response, checkpointMap), [SyncRequestType.AssetEditsV1]: () => this.syncAssetEditsV1(options, response, checkpointMap), - [SyncRequestType.PartnerAssetsV1]: () => this.syncPartnerAssetsV1(options, response, checkpointMap, session.id), + [SyncRequestType.PartnerAssetsV2]: () => this.syncPartnerAssetsV2(options, response, checkpointMap, session.id), [SyncRequestType.AssetMetadataV1]: () => this.syncAssetMetadataV1(options, response, checkpointMap, auth), [SyncRequestType.PartnerAssetExifsV1]: () => this.syncPartnerAssetExifsV1(options, response, checkpointMap, session.id), [SyncRequestType.AlbumsV1]: () => this.syncAlbumsV1(options, response, checkpointMap), [SyncRequestType.AlbumsV2]: () => this.syncAlbumsV2(options, response, checkpointMap), [SyncRequestType.AlbumUsersV1]: () => this.syncAlbumUsersV1(options, response, checkpointMap, session.id), - [SyncRequestType.AlbumAssetsV1]: () => this.syncAlbumAssetsV1(options, response, checkpointMap, session.id), + [SyncRequestType.AlbumAssetsV2]: () => this.syncAlbumAssetsV2(options, response, checkpointMap, session.id), [SyncRequestType.AlbumToAssetsV1]: () => this.syncAlbumToAssetsV1(options, response, checkpointMap, session.id), [SyncRequestType.AlbumAssetExifsV1]: () => this.syncAlbumAssetExifsV1(options, response, checkpointMap, session.id), @@ -178,13 +186,12 @@ export class SyncService extends BaseService { [SyncRequestType.StacksV1]: () => this.syncStackV1(options, response, checkpointMap), [SyncRequestType.PartnerStacksV1]: () => this.syncPartnerStackV1(options, response, checkpointMap, session.id), [SyncRequestType.PeopleV1]: () => this.syncPeopleV1(options, response, checkpointMap), - [SyncRequestType.AssetFacesV1]: async () => this.syncAssetFacesV1(options, response, checkpointMap), - [SyncRequestType.AssetFacesV2]: async () => this.syncAssetFacesV2(options, response, checkpointMap), + [SyncRequestType.AssetFacesV2]: () => this.syncAssetFacesV2(options, response, checkpointMap), [SyncRequestType.UserMetadataV1]: () => this.syncUserMetadataV1(options, response, checkpointMap), - }; + } as const; for (const type of SYNC_TYPES_ORDER.filter((type) => dto.types.includes(type))) { - const handler = handlers[type]; + const handler = handlers[type as keyof typeof handlers]; await handler(); } @@ -260,21 +267,31 @@ export class SyncService extends BaseService { } } - private async syncAssetsV1(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) { + private syncAssetsV1(): Promise { + throw new BadRequestException('SyncRequestType.AssetsV1 is deprecated, use SyncRequestType.AssetsV2 instead'); + } + + private async syncAssetsV2(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) { const deleteType = SyncEntityType.AssetDeleteV1; const deletes = this.syncRepository.asset.getDeletes({ ...options, ack: checkpointMap[deleteType] }); for await (const { id, ...data } of deletes) { send(response, { type: deleteType, ids: [id], data }); } - const upsertType = SyncEntityType.AssetV1; + const upsertType = SyncEntityType.AssetV2; const upserts = this.syncRepository.asset.getUpserts({ ...options, ack: checkpointMap[upsertType] }); for await (const { updateId, ...data } of upserts) { - send(response, { type: upsertType, ids: [updateId], data: mapSyncAssetV1(data) }); + send(response, { type: upsertType, ids: [updateId], data: mapSyncAssetV2(data) }); } } - private async syncPartnerAssetsV1( + private syncPartnerAssetsV1(): Promise { + throw new BadRequestException( + 'SyncRequestType.PartnerAssetsV1 is deprecated, use SyncRequestType.PartnerAssetsV2 instead', + ); + } + + private async syncPartnerAssetsV2( options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap, @@ -286,13 +303,13 @@ export class SyncService extends BaseService { send(response, { type: deleteType, ids: [id], data }); } - const backfillType = SyncEntityType.PartnerAssetBackfillV1; + const backfillType = SyncEntityType.PartnerAssetBackfillV2; const backfillCheckpoint = checkpointMap[backfillType]; const partners = await this.syncRepository.partner.getCreatedAfter({ ...options, afterCreateId: backfillCheckpoint?.updateId, }); - const upsertType = SyncEntityType.PartnerAssetV1; + const upsertType = SyncEntityType.PartnerAssetV2; const upsertCheckpoint = checkpointMap[upsertType]; if (upsertCheckpoint) { const endId = upsertCheckpoint.updateId; @@ -313,7 +330,7 @@ export class SyncService extends BaseService { send(response, { type: backfillType, ids: [createId, updateId], - data: mapSyncAssetV1(data), + data: mapSyncAssetV2(data), }); } @@ -329,7 +346,7 @@ export class SyncService extends BaseService { const upserts = this.syncRepository.partnerAsset.getUpserts({ ...options, ack: checkpointMap[upsertType] }); for await (const { updateId, ...data } of upserts) { - send(response, { type: upsertType, ids: [updateId], data: mapSyncAssetV1(data) }); + send(response, { type: upsertType, ids: [updateId], data: mapSyncAssetV2(data) }); } } @@ -490,20 +507,26 @@ export class SyncService extends BaseService { } } - private async syncAlbumAssetsV1( + private syncAlbumAssetsV1(): Promise { + throw new BadRequestException( + 'SyncRequestType.AlbumAssetsV1 is deprecated, use SyncRequestType.AlbumAssetsV2 instead', + ); + } + + private async syncAlbumAssetsV2( options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap, sessionId: string, ) { - const backfillType = SyncEntityType.AlbumAssetBackfillV1; + const backfillType = SyncEntityType.AlbumAssetBackfillV2; const backfillCheckpoint = checkpointMap[backfillType]; const albums = await this.syncRepository.album.getCreatedAfter({ ...options, afterCreateId: backfillCheckpoint?.updateId, }); - const updateType = SyncEntityType.AlbumAssetUpdateV1; - const createType = SyncEntityType.AlbumAssetCreateV1; + const updateType = SyncEntityType.AlbumAssetUpdateV2; + const createType = SyncEntityType.AlbumAssetCreateV2; const updateCheckpoint = checkpointMap[updateType]; const createCheckpoint = checkpointMap[createType]; if (createCheckpoint) { @@ -522,7 +545,7 @@ export class SyncService extends BaseService { ); for await (const { updateId, ...data } of backfill) { - send(response, { type: backfillType, ids: [createId, updateId], data: mapSyncAssetV1(data) }); + send(response, { type: backfillType, ids: [createId, updateId], data: mapSyncAssetV2(data) }); } sendEntityBackfillCompleteAck(response, backfillType, createId); @@ -541,7 +564,7 @@ export class SyncService extends BaseService { createCheckpoint, ); for await (const { updateId, ...data } of updates) { - send(response, { type: updateType, ids: [updateId], data: mapSyncAssetV1(data) }); + send(response, { type: updateType, ids: [updateId], data: mapSyncAssetV2(data) }); } } @@ -552,12 +575,12 @@ export class SyncService extends BaseService { send(response, { type: SyncEntityType.SyncAckV1, data: {}, - ackType: SyncEntityType.AlbumAssetUpdateV1, + ackType: SyncEntityType.AlbumAssetUpdateV2, ids: [options.nowId], }); first = false; } - send(response, { type: createType, ids: [updateId], data: mapSyncAssetV1(data) }); + send(response, { type: createType, ids: [updateId], data: mapSyncAssetV2(data) }); } } @@ -802,19 +825,10 @@ export class SyncService extends BaseService { } } - private async syncAssetFacesV1(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) { - const deleteType = SyncEntityType.AssetFaceDeleteV1; - const deletes = this.syncRepository.assetFace.getDeletes({ ...options, ack: checkpointMap[deleteType] }); - for await (const { id, ...data } of deletes) { - send(response, { type: deleteType, ids: [id], data }); - } - - const upsertType = SyncEntityType.AssetFaceV1; - const upserts = this.syncRepository.assetFace.getUpserts({ ...options, ack: checkpointMap[upsertType] }); - for await (const { updateId, ...data } of upserts) { - const v1 = syncAssetFaceV2ToV1(data); - send(response, { type: upsertType, ids: [updateId], data: v1 }); - } + private syncAssetFacesV1(): Promise { + throw new BadRequestException( + 'SyncRequestType.AssetFacesV1 is deprecated, use SyncRequestType.AssetFacesV2 instead', + ); } private async syncAssetFacesV2(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) { diff --git a/server/src/utils/duplicate.spec.ts b/server/src/utils/duplicate.spec.ts index d63f0d3e32..155438f1bd 100644 --- a/server/src/utils/duplicate.spec.ts +++ b/server/src/utils/duplicate.spec.ts @@ -16,7 +16,7 @@ const createAsset = ( type: AssetType.Image, thumbhash: null, localDateTime: new Date().toISOString(), - duration: '0:00:00.00000', + duration: 0, hasMetadata: true, width: 1920, height: 1080, diff --git a/server/test/medium/specs/sync/sync-album-asset.spec.ts b/server/test/medium/specs/sync/sync-album-asset.spec.ts index 123b6f9484..1418f35589 100644 --- a/server/test/medium/specs/sync/sync-album-asset.spec.ts +++ b/server/test/medium/specs/sync/sync-album-asset.spec.ts @@ -15,13 +15,13 @@ const setup = async (db?: Kysely) => { }; const updateSyncAck = { - ack: expect.stringContaining(SyncEntityType.AlbumAssetUpdateV1), + ack: expect.stringContaining(SyncEntityType.AlbumAssetUpdateV2), data: {}, type: SyncEntityType.SyncAckV1, }; const backfillSyncAck = { - ack: expect.stringContaining(SyncEntityType.AlbumAssetBackfillV1), + ack: expect.stringContaining(SyncEntityType.AlbumAssetBackfillV2), data: {}, type: SyncEntityType.SyncAckV1, }; @@ -30,7 +30,7 @@ beforeAll(async () => { defaultDatabase = await getKyselyDB(); }); -describe(SyncRequestType.AlbumAssetsV1, () => { +describe(SyncRequestType.AlbumAssetsV2, () => { it('should detect and sync the first album asset', async () => { const originalFileName = 'firstAsset'; const checksum = '1115vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA='; @@ -48,7 +48,7 @@ describe(SyncRequestType.AlbumAssetsV1, () => { fileModifiedAt: date, localDateTime: date, deletedAt: null, - duration: '0:10:00.00000', + duration: 600_000, livePhotoVideoId: null, stackId: null, libraryId: null, @@ -59,7 +59,7 @@ describe(SyncRequestType.AlbumAssetsV1, () => { await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id }); await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.Editor }); - const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]); + const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV2]); expect(response).toEqual([ updateSyncAck, { @@ -85,13 +85,13 @@ describe(SyncRequestType.AlbumAssetsV1, () => { height: asset.height, isEdited: asset.isEdited, }, - type: SyncEntityType.AlbumAssetCreateV1, + type: SyncEntityType.AlbumAssetCreateV2, }, expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), ]); await ctx.syncAckAll(auth, response); - await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumAssetsV1]); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumAssetsV2]); }); it('should sync album asset for own user', async () => { @@ -100,13 +100,13 @@ describe(SyncRequestType.AlbumAssetsV1, () => { const { album } = await ctx.newAlbum({ ownerId: auth.user.id }); await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id }); - await expect(ctx.syncStream(auth, [SyncRequestType.AssetsV1])).resolves.toEqual([ - expect.objectContaining({ type: SyncEntityType.AssetV1 }), + await expect(ctx.syncStream(auth, [SyncRequestType.AssetsV2])).resolves.toEqual([ + expect.objectContaining({ type: SyncEntityType.AssetV2 }), expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), ]); - await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1])).resolves.toEqual([ + await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV2])).resolves.toEqual([ expect.objectContaining({ type: SyncEntityType.SyncAckV1 }), - expect.objectContaining({ type: SyncEntityType.AlbumAssetCreateV1 }), + expect.objectContaining({ type: SyncEntityType.AlbumAssetCreateV2 }), expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), ]); }); @@ -122,11 +122,11 @@ describe(SyncRequestType.AlbumAssetsV1, () => { const { session } = await ctx.newSession({ userId: user3.id }); const authUser3 = factory.auth({ session, user: user3 }); - await expect(ctx.syncStream(authUser3, [SyncRequestType.AssetsV1])).resolves.toEqual([ - expect.objectContaining({ type: SyncEntityType.AssetV1 }), + await expect(ctx.syncStream(authUser3, [SyncRequestType.AssetsV2])).resolves.toEqual([ + expect.objectContaining({ type: SyncEntityType.AssetV2 }), expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), ]); - await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumAssetsV1]); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumAssetsV2]); }); it('should backfill album assets when a user shares an album with you', async () => { @@ -147,7 +147,7 @@ describe(SyncRequestType.AlbumAssetsV1, () => { await wait(2); await ctx.newAlbumUser({ albumId: album1.id, userId: auth.user.id, role: AlbumUserRole.Editor }); - const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]); + const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV2]); expect(response).toEqual([ updateSyncAck, { @@ -155,7 +155,7 @@ describe(SyncRequestType.AlbumAssetsV1, () => { data: expect.objectContaining({ id: asset2User2.id, }), - type: SyncEntityType.AlbumAssetCreateV1, + type: SyncEntityType.AlbumAssetCreateV2, }, expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), ]); @@ -166,21 +166,21 @@ describe(SyncRequestType.AlbumAssetsV1, () => { await ctx.newAlbumUser({ albumId: album2.id, userId: auth.user.id, role: AlbumUserRole.Editor }); // should backfill the album user - const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]); + const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV2]); expect(newResponse).toEqual([ { ack: expect.any(String), data: expect.objectContaining({ id: asset1User2.id, }), - type: SyncEntityType.AlbumAssetBackfillV1, + type: SyncEntityType.AlbumAssetBackfillV2, }, { ack: expect.any(String), data: expect.objectContaining({ id: asset2User2.id, }), - type: SyncEntityType.AlbumAssetBackfillV1, + type: SyncEntityType.AlbumAssetBackfillV2, }, backfillSyncAck, updateSyncAck, @@ -189,13 +189,13 @@ describe(SyncRequestType.AlbumAssetsV1, () => { data: expect.objectContaining({ id: asset3User2.id, }), - type: SyncEntityType.AlbumAssetCreateV1, + type: SyncEntityType.AlbumAssetCreateV2, }, expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), ]); await ctx.syncAckAll(auth, newResponse); - await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumAssetsV1]); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumAssetsV2]); }); it('should sync old assets when a user adds them to an album they share you', async () => { @@ -211,7 +211,7 @@ describe(SyncRequestType.AlbumAssetsV1, () => { await ctx.newAlbumAsset({ albumId: album1.id, assetId: album1Asset.id }); await ctx.newAlbumUser({ albumId: album1.id, userId: auth.user.id, role: AlbumUserRole.Editor }); - const firstAlbumResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]); + const firstAlbumResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV2]); expect(firstAlbumResponse).toEqual([ updateSyncAck, { @@ -219,7 +219,7 @@ describe(SyncRequestType.AlbumAssetsV1, () => { data: expect.objectContaining({ id: album1Asset.id, }), - type: SyncEntityType.AlbumAssetCreateV1, + type: SyncEntityType.AlbumAssetCreateV2, }, expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), ]); @@ -228,14 +228,14 @@ describe(SyncRequestType.AlbumAssetsV1, () => { await ctx.newAlbumUser({ albumId: album2.id, userId: auth.user.id, role: AlbumUserRole.Editor }); - const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]); + const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV2]); expect(response).toEqual([ { ack: expect.any(String), data: expect.objectContaining({ id: firstAsset.id, }), - type: SyncEntityType.AlbumAssetBackfillV1, + type: SyncEntityType.AlbumAssetBackfillV2, }, backfillSyncAck, expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), @@ -248,7 +248,7 @@ describe(SyncRequestType.AlbumAssetsV1, () => { await wait(2); // should backfill the new asset even though it's older than the first asset - const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]); + const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV2]); expect(newResponse).toEqual([ updateSyncAck, { @@ -256,13 +256,13 @@ describe(SyncRequestType.AlbumAssetsV1, () => { data: expect.objectContaining({ id: secondAsset.id, }), - type: SyncEntityType.AlbumAssetCreateV1, + type: SyncEntityType.AlbumAssetCreateV2, }, expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), ]); await ctx.syncAckAll(auth, newResponse); - await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumAssetsV1]); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumAssetsV2]); }); it('should sync asset updates for an album shared with you', async () => { @@ -274,7 +274,7 @@ describe(SyncRequestType.AlbumAssetsV1, () => { await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id }); await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.Editor }); - const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]); + const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV2]); expect(response).toEqual([ updateSyncAck, { @@ -282,7 +282,7 @@ describe(SyncRequestType.AlbumAssetsV1, () => { data: expect.objectContaining({ id: asset.id, }), - type: SyncEntityType.AlbumAssetCreateV1, + type: SyncEntityType.AlbumAssetCreateV2, }, expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), ]); @@ -296,7 +296,7 @@ describe(SyncRequestType.AlbumAssetsV1, () => { isFavorite: true, }); - const updateResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]); + const updateResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV2]); expect(updateResponse).toEqual([ { ack: expect.any(String), @@ -304,7 +304,7 @@ describe(SyncRequestType.AlbumAssetsV1, () => { id: asset.id, isFavorite: true, }), - type: SyncEntityType.AlbumAssetUpdateV1, + type: SyncEntityType.AlbumAssetUpdateV2, }, expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), ]); diff --git a/server/test/medium/specs/sync/sync-asset-face.spec.ts b/server/test/medium/specs/sync/sync-asset-face.spec.ts index 34a1e8e73c..74d4c536f1 100644 --- a/server/test/medium/specs/sync/sync-asset-face.spec.ts +++ b/server/test/medium/specs/sync/sync-asset-face.spec.ts @@ -18,14 +18,14 @@ beforeAll(async () => { defaultDatabase = await getKyselyDB(); }); -describe(SyncEntityType.AssetFaceV1, () => { +describe(SyncEntityType.AssetFaceV2, () => { it('should detect and sync the first asset face', async () => { const { auth, ctx } = await setup(); const { asset } = await ctx.newAsset({ ownerId: auth.user.id }); const { person } = await ctx.newPerson({ ownerId: auth.user.id }); const { assetFace } = await ctx.newAssetFace({ assetId: asset.id, personId: person.id }); - const response = await ctx.syncStream(auth, [SyncRequestType.AssetFacesV1]); + const response = await ctx.syncStream(auth, [SyncRequestType.AssetFacesV2]); expect(response).toEqual([ { ack: expect.any(String), @@ -41,13 +41,13 @@ describe(SyncEntityType.AssetFaceV1, () => { boundingBoxY2: assetFace.boundingBoxY2, sourceType: assetFace.sourceType, }), - type: 'AssetFaceV1', + type: 'AssetFaceV2', }, expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), ]); await ctx.syncAckAll(auth, response); - await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetFacesV1]); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetFacesV2]); }); it('should detect and sync a deleted asset face', async () => { @@ -57,7 +57,7 @@ describe(SyncEntityType.AssetFaceV1, () => { const { assetFace } = await ctx.newAssetFace({ assetId: asset.id }); await personRepo.deleteAssetFace(assetFace.id); - const response = await ctx.syncStream(auth, [SyncRequestType.AssetFacesV1]); + const response = await ctx.syncStream(auth, [SyncRequestType.AssetFacesV2]); expect(response).toEqual([ { ack: expect.any(String), @@ -70,7 +70,7 @@ describe(SyncEntityType.AssetFaceV1, () => { ]); await ctx.syncAckAll(auth, response); - await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetFacesV1]); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetFacesV2]); }); it('should not sync an asset face or asset face delete for an unrelated user', async () => { @@ -82,19 +82,19 @@ describe(SyncEntityType.AssetFaceV1, () => { const { assetFace } = await ctx.newAssetFace({ assetId: asset.id }); const auth2 = factory.auth({ session, user: user2 }); - expect(await ctx.syncStream(auth2, [SyncRequestType.AssetFacesV1])).toEqual([ - expect.objectContaining({ type: SyncEntityType.AssetFaceV1 }), + expect(await ctx.syncStream(auth2, [SyncRequestType.AssetFacesV2])).toEqual([ + expect.objectContaining({ type: SyncEntityType.AssetFaceV2 }), expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), ]); - await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetFacesV1]); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetFacesV2]); await personRepo.deleteAssetFace(assetFace.id); - expect(await ctx.syncStream(auth2, [SyncRequestType.AssetFacesV1])).toEqual([ + expect(await ctx.syncStream(auth2, [SyncRequestType.AssetFacesV2])).toEqual([ expect.objectContaining({ type: SyncEntityType.AssetFaceDeleteV1 }), expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), ]); - await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetFacesV1]); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetFacesV2]); }); }); diff --git a/server/test/medium/specs/sync/sync-asset.spec.ts b/server/test/medium/specs/sync/sync-asset.spec.ts index a1a898d9b3..8b06036854 100644 --- a/server/test/medium/specs/sync/sync-asset.spec.ts +++ b/server/test/medium/specs/sync/sync-asset.spec.ts @@ -18,7 +18,7 @@ beforeAll(async () => { defaultDatabase = await getKyselyDB(); }); -describe(SyncEntityType.AssetV1, () => { +describe(SyncEntityType.AssetV2, () => { it('should detect and sync the first asset', async () => { const originalFileName = 'firstAsset'; const checksum = '1115vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA='; @@ -35,13 +35,13 @@ describe(SyncEntityType.AssetV1, () => { fileModifiedAt: date, localDateTime: date, deletedAt: null, - duration: '0:10:00.00000', + duration: 600_000, libraryId: null, width: 1920, height: 1080, }); - const response = await ctx.syncStream(auth, [SyncRequestType.AssetsV1]); + const response = await ctx.syncStream(auth, [SyncRequestType.AssetsV2]); expect(response).toEqual([ { ack: expect.any(String), @@ -66,13 +66,13 @@ describe(SyncEntityType.AssetV1, () => { height: asset.height, isEdited: asset.isEdited, }, - type: 'AssetV1', + type: 'AssetV2', }, expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), ]); await ctx.syncAckAll(auth, response); - await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetsV1]); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetsV2]); }); it('should detect and sync a deleted asset', async () => { @@ -81,7 +81,7 @@ describe(SyncEntityType.AssetV1, () => { const { asset } = await ctx.newAsset({ ownerId: auth.user.id }); await assetRepo.remove(asset); - const response = await ctx.syncStream(auth, [SyncRequestType.AssetsV1]); + const response = await ctx.syncStream(auth, [SyncRequestType.AssetsV2]); expect(response).toEqual([ { ack: expect.any(String), @@ -94,7 +94,7 @@ describe(SyncEntityType.AssetV1, () => { ]); await ctx.syncAckAll(auth, response); - await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetsV1]); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetsV2]); }); it('should not sync an asset or asset delete for an unrelated user', async () => { @@ -105,17 +105,17 @@ describe(SyncEntityType.AssetV1, () => { const { asset } = await ctx.newAsset({ ownerId: user2.id }); const auth2 = factory.auth({ session, user: user2 }); - expect(await ctx.syncStream(auth2, [SyncRequestType.AssetsV1])).toEqual([ - expect.objectContaining({ type: SyncEntityType.AssetV1 }), + expect(await ctx.syncStream(auth2, [SyncRequestType.AssetsV2])).toEqual([ + expect.objectContaining({ type: SyncEntityType.AssetV2 }), expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), ]); - await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetsV1]); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetsV2]); await assetRepo.remove(asset); - expect(await ctx.syncStream(auth2, [SyncRequestType.AssetsV1])).toEqual([ + expect(await ctx.syncStream(auth2, [SyncRequestType.AssetsV2])).toEqual([ expect.objectContaining({ type: SyncEntityType.AssetDeleteV1 }), expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), ]); - await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetsV1]); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetsV2]); }); }); diff --git a/server/test/medium/specs/sync/sync-complete.spec.ts b/server/test/medium/specs/sync/sync-complete.spec.ts index 8a94061631..95beb7b294 100644 --- a/server/test/medium/specs/sync/sync-complete.spec.ts +++ b/server/test/medium/specs/sync/sync-complete.spec.ts @@ -24,7 +24,7 @@ describe(SyncEntityType.SyncCompleteV1, () => { it('should work', async () => { const { auth, ctx } = await setup(); - await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetsV1]); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetsV2]); }); it('should detect an old checkpoint and send back a reset', async () => { @@ -39,7 +39,7 @@ describe(SyncEntityType.SyncCompleteV1, () => { }, ]); - const response = await ctx.syncStream(auth, [SyncRequestType.AssetsV1]); + const response = await ctx.syncStream(auth, [SyncRequestType.AssetsV2]); expect(response).toEqual([{ type: SyncEntityType.SyncResetV1, data: {}, ack: 'SyncResetV1|reset' }]); }); @@ -55,6 +55,6 @@ describe(SyncEntityType.SyncCompleteV1, () => { }, ]); - await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetsV1]); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetsV2]); }); }); diff --git a/server/test/medium/specs/sync/sync-partner-asset.spec.ts b/server/test/medium/specs/sync/sync-partner-asset.spec.ts index 345d4a1e29..face352970 100644 --- a/server/test/medium/specs/sync/sync-partner-asset.spec.ts +++ b/server/test/medium/specs/sync/sync-partner-asset.spec.ts @@ -20,7 +20,7 @@ beforeAll(async () => { defaultDatabase = await getKyselyDB(); }); -describe(SyncRequestType.PartnerAssetsV1, () => { +describe(SyncRequestType.PartnerAssetsV2, () => { it('should detect and sync the first partner asset', async () => { const { auth, ctx } = await setup(); @@ -39,13 +39,13 @@ describe(SyncRequestType.PartnerAssetsV1, () => { fileModifiedAt: date, localDateTime: date, deletedAt: null, - duration: '0:10:00.00000', + duration: 600_000, libraryId: null, }); await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id }); - const response = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV1]); + const response = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV2]); expect(response).toEqual([ { ack: expect.any(String), @@ -70,13 +70,13 @@ describe(SyncRequestType.PartnerAssetsV1, () => { width: null, height: null, }, - type: SyncEntityType.PartnerAssetV1, + type: SyncEntityType.PartnerAssetV2, }, expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), ]); await ctx.syncAckAll(auth, response); - await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetsV1]); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetsV2]); }); it('should detect and sync a deleted partner asset', async () => { @@ -88,7 +88,7 @@ describe(SyncRequestType.PartnerAssetsV1, () => { await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id }); await assetRepo.remove(asset); - const response = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV1]); + const response = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV2]); expect(response).toEqual([ { ack: expect.any(String), @@ -101,7 +101,7 @@ describe(SyncRequestType.PartnerAssetsV1, () => { ]); await ctx.syncAckAll(auth, response); - await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetsV1]); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetsV2]); }); it('should not sync a deleted partner asset due to a user delete', async () => { @@ -112,7 +112,7 @@ describe(SyncRequestType.PartnerAssetsV1, () => { await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id }); await ctx.newAsset({ ownerId: user2.id }); await userRepo.delete({ id: user2.id }, true); - await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetsV1]); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetsV2]); }); it('should not sync a deleted partner asset due to a partner delete (unshare)', async () => { @@ -122,12 +122,12 @@ describe(SyncRequestType.PartnerAssetsV1, () => { const { user: user2 } = await ctx.newUser(); await ctx.newAsset({ ownerId: user2.id }); const { partner } = await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id }); - await expect(ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toEqual([ - expect.objectContaining({ type: SyncEntityType.PartnerAssetV1 }), + await expect(ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV2])).resolves.toEqual([ + expect.objectContaining({ type: SyncEntityType.PartnerAssetV2 }), expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), ]); await partnerRepo.remove(partner); - await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetsV1]); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetsV2]); }); it('should not sync an asset or asset delete for own user', async () => { @@ -138,19 +138,19 @@ describe(SyncRequestType.PartnerAssetsV1, () => { const { asset } = await ctx.newAsset({ ownerId: auth.user.id }); await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id }); - await expect(ctx.syncStream(auth, [SyncRequestType.AssetsV1])).resolves.toEqual([ - expect.objectContaining({ type: SyncEntityType.AssetV1 }), + await expect(ctx.syncStream(auth, [SyncRequestType.AssetsV2])).resolves.toEqual([ + expect.objectContaining({ type: SyncEntityType.AssetV2 }), expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), ]); - await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetsV1]); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetsV2]); await assetRepo.remove(asset); - await expect(ctx.syncStream(auth, [SyncRequestType.AssetsV1])).resolves.toEqual([ + await expect(ctx.syncStream(auth, [SyncRequestType.AssetsV2])).resolves.toEqual([ expect.objectContaining({ type: SyncEntityType.AssetDeleteV1 }), expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), ]); - await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetsV1]); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetsV2]); }); it('should not sync an asset or asset delete for unrelated user', async () => { @@ -162,19 +162,19 @@ describe(SyncRequestType.PartnerAssetsV1, () => { const { asset } = await ctx.newAsset({ ownerId: user2.id }); const auth2 = factory.auth({ session, user: user2 }); - await expect(ctx.syncStream(auth2, [SyncRequestType.AssetsV1])).resolves.toEqual([ - expect.objectContaining({ type: SyncEntityType.AssetV1 }), + await expect(ctx.syncStream(auth2, [SyncRequestType.AssetsV2])).resolves.toEqual([ + expect.objectContaining({ type: SyncEntityType.AssetV2 }), expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), ]); - await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetsV1]); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetsV2]); await assetRepo.remove(asset); - await expect(ctx.syncStream(auth2, [SyncRequestType.AssetsV1])).resolves.toEqual([ + await expect(ctx.syncStream(auth2, [SyncRequestType.AssetsV2])).resolves.toEqual([ expect.objectContaining({ type: SyncEntityType.AssetDeleteV1 }), expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), ]); - await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetsV1]); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetsV2]); }); it('should backfill partner assets when a partner shared their library with you', async () => { @@ -187,14 +187,14 @@ describe(SyncRequestType.PartnerAssetsV1, () => { const { asset: assetUser2 } = await ctx.newAsset({ ownerId: user2.id }); await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id }); - const response = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV1]); + const response = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV2]); expect(response).toEqual([ { ack: expect.any(String), data: expect.objectContaining({ id: assetUser2.id, }), - type: SyncEntityType.PartnerAssetV1, + type: SyncEntityType.PartnerAssetV2, }, expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), ]); @@ -202,17 +202,17 @@ describe(SyncRequestType.PartnerAssetsV1, () => { await ctx.syncAckAll(auth, response); await ctx.newPartner({ sharedById: user3.id, sharedWithId: auth.user.id }); - const newResponse = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV1]); + const newResponse = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV2]); expect(newResponse).toEqual([ { ack: expect.any(String), data: expect.objectContaining({ id: assetUser3.id, }), - type: SyncEntityType.PartnerAssetBackfillV1, + type: SyncEntityType.PartnerAssetBackfillV2, }, { - ack: expect.stringContaining(SyncEntityType.PartnerAssetBackfillV1), + ack: expect.stringContaining(SyncEntityType.PartnerAssetBackfillV2), data: {}, type: SyncEntityType.SyncAckV1, }, @@ -220,7 +220,7 @@ describe(SyncRequestType.PartnerAssetsV1, () => { ]); await ctx.syncAckAll(auth, newResponse); - await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetsV1]); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetsV2]); }); it('should only backfill partner assets created prior to the current partner asset checkpoint', async () => { @@ -235,31 +235,31 @@ describe(SyncRequestType.PartnerAssetsV1, () => { const { asset: asset2User3 } = await ctx.newAsset({ ownerId: user3.id }); await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id }); - const response = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV1]); + const response = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV2]); expect(response).toEqual([ { ack: expect.any(String), data: expect.objectContaining({ id: assetUser2.id, }), - type: SyncEntityType.PartnerAssetV1, + type: SyncEntityType.PartnerAssetV2, }, expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), ]); await ctx.syncAckAll(auth, response); await ctx.newPartner({ sharedById: user3.id, sharedWithId: auth.user.id }); - const newResponse = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV1]); + const newResponse = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV2]); expect(newResponse).toEqual([ { ack: expect.any(String), data: expect.objectContaining({ id: assetUser3.id, }), - type: SyncEntityType.PartnerAssetBackfillV1, + type: SyncEntityType.PartnerAssetBackfillV2, }, { - ack: expect.stringContaining(SyncEntityType.PartnerAssetBackfillV1), + ack: expect.stringContaining(SyncEntityType.PartnerAssetBackfillV2), data: {}, type: SyncEntityType.SyncAckV1, }, @@ -268,12 +268,12 @@ describe(SyncRequestType.PartnerAssetsV1, () => { data: expect.objectContaining({ id: asset2User3.id, }), - type: SyncEntityType.PartnerAssetV1, + type: SyncEntityType.PartnerAssetV2, }, expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), ]); await ctx.syncAckAll(auth, newResponse); - await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetsV1]); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetsV2]); }); }); diff --git a/server/test/medium/specs/sync/sync-reset.spec.ts b/server/test/medium/specs/sync/sync-reset.spec.ts index 9a4c33c1f2..b99d8d7df0 100644 --- a/server/test/medium/specs/sync/sync-reset.spec.ts +++ b/server/test/medium/specs/sync/sync-reset.spec.ts @@ -21,7 +21,7 @@ describe(SyncEntityType.SyncResetV1, () => { it('should work', async () => { const { auth, ctx } = await setup(); - await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetsV1]); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetsV2]); }); it('should detect a pending sync reset', async () => { @@ -31,7 +31,7 @@ describe(SyncEntityType.SyncResetV1, () => { isPendingSyncReset: true, }); - const response = await ctx.syncStream(auth, [SyncRequestType.AssetsV1]); + const response = await ctx.syncStream(auth, [SyncRequestType.AssetsV2]); expect(response).toEqual([{ type: SyncEntityType.SyncResetV1, data: {}, ack: 'SyncResetV1|reset' }]); }); @@ -40,8 +40,8 @@ describe(SyncEntityType.SyncResetV1, () => { await ctx.newAsset({ ownerId: user.id }); - await expect(ctx.syncStream(auth, [SyncRequestType.AssetsV1])).resolves.toEqual([ - expect.objectContaining({ type: SyncEntityType.AssetV1 }), + await expect(ctx.syncStream(auth, [SyncRequestType.AssetsV2])).resolves.toEqual([ + expect.objectContaining({ type: SyncEntityType.AssetV2 }), expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), ]); @@ -49,7 +49,7 @@ describe(SyncEntityType.SyncResetV1, () => { isPendingSyncReset: true, }); - await expect(ctx.syncStream(auth, [SyncRequestType.AssetsV1])).resolves.toEqual([ + await expect(ctx.syncStream(auth, [SyncRequestType.AssetsV2])).resolves.toEqual([ { type: SyncEntityType.SyncResetV1, data: {}, ack: 'SyncResetV1|reset' }, ]); }); @@ -63,8 +63,8 @@ describe(SyncEntityType.SyncResetV1, () => { isPendingSyncReset: true, }); - await expect(ctx.syncStream(auth, [SyncRequestType.AssetsV1], true)).resolves.toEqual([ - expect.objectContaining({ type: SyncEntityType.AssetV1 }), + await expect(ctx.syncStream(auth, [SyncRequestType.AssetsV2], true)).resolves.toEqual([ + expect.objectContaining({ type: SyncEntityType.AssetV2 }), expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), ]); }); @@ -74,20 +74,20 @@ describe(SyncEntityType.SyncResetV1, () => { await ctx.newAsset({ ownerId: user.id }); - const response = await ctx.syncStream(auth, [SyncRequestType.AssetsV1]); + const response = await ctx.syncStream(auth, [SyncRequestType.AssetsV2]); await ctx.syncAckAll(auth, response); await ctx.get(SessionRepository).update(auth.session!.id, { isPendingSyncReset: true, }); - const resetResponse = await ctx.syncStream(auth, [SyncRequestType.AssetsV1]); + const resetResponse = await ctx.syncStream(auth, [SyncRequestType.AssetsV2]); await ctx.syncAckAll(auth, resetResponse); - const postResetResponse = await ctx.syncStream(auth, [SyncRequestType.AssetsV1]); + const postResetResponse = await ctx.syncStream(auth, [SyncRequestType.AssetsV2]); expect(postResetResponse).toEqual([ - expect.objectContaining({ type: SyncEntityType.AssetV1 }), + expect.objectContaining({ type: SyncEntityType.AssetV2 }), expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), ]); }); diff --git a/web/src/lib/components/assets/thumbnail/Thumbnail.svelte b/web/src/lib/components/assets/thumbnail/Thumbnail.svelte index 99608eabcc..63e93169bb 100644 --- a/web/src/lib/components/assets/thumbnail/Thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/Thumbnail.svelte @@ -5,7 +5,6 @@ import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte'; import { locale, playVideoThumbnailOnHover } from '$lib/stores/preferences.store'; import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils'; - import { timeToSeconds } from '$lib/utils/date-time'; import { moveFocus } from '$lib/utils/focus-util'; import { currentUrlReplaceAssetId } from '$lib/utils/navigation'; import { getAltText } from '$lib/utils/thumbnail-util'; @@ -274,7 +273,7 @@ url={getAssetPlaybackUrl({ id: asset.id, cacheKey: asset.thumbhash })} enablePlayback={mouseOver && $playVideoThumbnailOnHover} curve={selected} - durationInSeconds={asset.duration ? timeToSeconds(asset.duration) : 0} + durationInSeconds={asset.duration ? asset.duration / 1000 : 0} playbackOnIconHover={!$playVideoThumbnailOnHover} /> diff --git a/web/src/lib/managers/timeline-manager/types.ts b/web/src/lib/managers/timeline-manager/types.ts index 1437e5701b..0e85802900 100644 --- a/web/src/lib/managers/timeline-manager/types.ts +++ b/web/src/lib/managers/timeline-manager/types.ts @@ -29,7 +29,7 @@ export type TimelineAsset = { isVideo: boolean; isImage: boolean; stack: AssetStackResponseDto | null; - duration: string | null; + duration: number | null; projectionType: string | null; livePhotoVideoId: string | null; city: string | null; diff --git a/web/src/lib/utils.spec.ts b/web/src/lib/utils.spec.ts index 7b3ae9ecb2..9ecc7f548e 100644 --- a/web/src/lib/utils.spec.ts +++ b/web/src/lib/utils.spec.ts @@ -50,7 +50,7 @@ describe('utils', () => { originalPath: 'image.gif', originalMimeType: 'image/gif', type: AssetTypeEnum.Image, - duration: '2.0', + duration: 2000, }); const url = getAssetUrl({ asset }); @@ -65,7 +65,7 @@ describe('utils', () => { originalPath: 'image.webp', originalMimeType: 'image/webp', type: AssetTypeEnum.Image, - duration: '2.0', + duration: 2000, }); const url = getAssetUrl({ asset }); @@ -119,7 +119,7 @@ describe('utils', () => { originalPath: 'image.gif', originalMimeType: 'image/gif', type: AssetTypeEnum.Image, - duration: '2.0', + duration: 2000, }); const sharedLink = sharedLinkFactory.build({ allowDownload: true, showMetadata: true, assets: [asset] }); @@ -134,7 +134,7 @@ describe('utils', () => { originalPath: 'image.gif', originalMimeType: 'image/gif', type: AssetTypeEnum.Image, - duration: '2.0', + duration: 2000, }); const sharedLink = sharedLinkFactory.build({ allowDownload: false, assets: [asset] }); @@ -150,7 +150,7 @@ describe('utils', () => { originalPath: 'image.gif', originalMimeType: 'image/gif', type: AssetTypeEnum.Image, - duration: '2.0', + duration: 2000, }); const sharedLink = sharedLinkFactory.build({ showMetadata: false, assets: [asset] }); diff --git a/web/src/lib/utils/date-time.spec.ts b/web/src/lib/utils/date-time.spec.ts index 830d96d45c..f91a74c6b9 100644 --- a/web/src/lib/utils/date-time.spec.ts +++ b/web/src/lib/utils/date-time.spec.ts @@ -1,53 +1,5 @@ import { writable } from 'svelte/store'; -import { getAlbumDateRange, getShortDateRange, timeToSeconds } from './date-time'; - -describe('converting time to seconds', () => { - it('parses hh:mm:ss correctly', () => { - expect(timeToSeconds('01:02:03')).toBeCloseTo(3723); - }); - - it('parses hh:mm:ss.SSS correctly', () => { - expect(timeToSeconds('01:02:03.456')).toBeCloseTo(3723.456); - }); - - it('parses h:m:s.S correctly', () => { - expect(timeToSeconds('1:2:3.4')).toBe(0); // Non-standard format, Luxon returns NaN - }); - - it('parses hhh:mm:ss.SSS correctly', () => { - expect(timeToSeconds('100:02:03.456')).toBe(0); // Non-standard format, Luxon returns NaN - }); - - it('ignores ignores double milliseconds hh:mm:ss.SSS.SSSSSS', () => { - expect(timeToSeconds('01:02:03.456.123456')).toBe(0); // Non-standard format, Luxon returns NaN - }); - - // Test edge cases that can cause crashes - it('handles "0" string input', () => { - expect(timeToSeconds('0')).toBe(0); - }); - - it('handles empty string input', () => { - expect(timeToSeconds('')).toBe(0); - }); - - it('parses HH:MM format correctly', () => { - expect(timeToSeconds('01:02')).toBe(3720); // 1 hour 2 minutes = 3720 seconds - }); - - it('handles malformed time strings', () => { - expect(timeToSeconds('invalid')).toBe(0); - }); - - it('parses single hour format correctly', () => { - expect(timeToSeconds('01')).toBe(3600); // Luxon interprets "01" as 1 hour - }); - - it('handles time strings with invalid numbers', () => { - expect(timeToSeconds('aa:bb:cc')).toBe(0); - expect(timeToSeconds('01:bb:03')).toBe(0); - }); -}); +import { getAlbumDateRange, getShortDateRange } from './date-time'; describe('getShortDateRange', () => { beforeEach(() => { diff --git a/web/src/lib/utils/date-time.ts b/web/src/lib/utils/date-time.ts index f39697fa11..d06f92376b 100644 --- a/web/src/lib/utils/date-time.ts +++ b/web/src/lib/utils/date-time.ts @@ -1,20 +1,8 @@ -import { DateTime, Duration } from 'luxon'; +import { DateTime } from 'luxon'; import { get } from 'svelte/store'; import { dateFormats } from '$lib/constants'; import { locale } from '$lib/stores/preferences.store'; -/** - * Convert time like `01:02:03.456` to seconds. - */ -export function timeToSeconds(time: string) { - if (!time || time === '0') { - return 0; - } - - const seconds = Duration.fromISOTime(time).as('seconds'); - - return Number.isNaN(seconds) ? 0 : seconds; -} export function parseUtcDate(date: string) { return DateTime.fromISO(date, { zone: 'UTC' }).toUTC(); } diff --git a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/MemoryViewer.svelte b/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/MemoryViewer.svelte index cd415fc704..f440207b26 100644 --- a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/MemoryViewer.svelte +++ b/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/MemoryViewer.svelte @@ -95,18 +95,10 @@ }; const setProgressDuration = (asset: TimelineAsset) => { - if (asset.isVideo) { - const timeParts = asset.duration!.split(':').map(Number); - const durationInMilliseconds = (timeParts[0] * 3600 + timeParts[1] * 60 + timeParts[2]) * 1000; - progressBarController = new Tween(0, { - duration: (from: number, to: number) => (to ? durationInMilliseconds * (to - from) : 0), - }); - } else { - progressBarController = new Tween(0, { - duration: (from: number, to: number) => - to ? authManager.preferences.memories.duration * 1000 * (to - from) : 0, - }); - } + progressBarController = new Tween(0, { + duration: (from: number, to: number) => + to ? (asset.isVideo ? asset.duration! : authManager.preferences.memories.duration * 1000) * (to - from) : 0, + }); }; const handleNextAsset = () => handleNavigate(current?.next?.asset);