diff --git a/e2e/src/api/specs/sync.e2e-spec.ts b/e2e/src/api/specs/sync.e2e-spec.ts new file mode 100644 index 0000000000000..e4b155d34ea11 --- /dev/null +++ b/e2e/src/api/specs/sync.e2e-spec.ts @@ -0,0 +1,83 @@ +import { LoginResponseDto, login, signUpAdmin } from '@immich/sdk'; +import { loginDto, signupDto, uuidDto } from 'src/fixtures'; +import { errorDto } from 'src/responses'; +import { app, utils } from 'src/utils'; +import request from 'supertest'; +import { beforeAll, describe, expect, it } from 'vitest'; + +describe('/sync', () => { + let admin: LoginResponseDto; + + beforeAll(async () => { + await utils.resetDatabase(); + await signUpAdmin({ signUpDto: signupDto.admin }); + admin = await login({ loginCredentialDto: loginDto.admin }); + }); + + describe('GET /sync/acknowledge', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).post('/sync/acknowledge'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should work', async () => { + const { status } = await request(app) + .post('/sync/acknowledge') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({}); + expect(status).toBe(204); + }); + + it('should work with an album sync date', async () => { + const { status } = await request(app) + .post('/sync/acknowledge') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ + album: { + id: uuidDto.dummy, + timestamp: '2024-10-23T21:01:07.732Z', + }, + }); + expect(status).toBe(204); + }); + }); + + describe('GET /sync/stream', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).post('/sync/stream'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require at least type', async () => { + const { status, body } = await request(app) + .post('/sync/stream') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ types: [] }); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest()); + }); + + it('should require valid types', async () => { + const { status, body } = await request(app) + .post('/sync/stream') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ types: ['invalid'] }); + expect(status).toBe(400); + expect(body).toEqual( + errorDto.badRequest([expect.stringContaining('each value in types must be one of the following values')]), + ); + }); + + it('should accept a valid type', async () => { + const response = await request(app) + .post('/sync/stream') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ types: ['asset'] }); + expect(response.status).toBe(200); + expect(response.get('Content-Type')).toBe('application/jsonlines+json; charset=utf-8'); + expect(response.body).toEqual(''); + }); + }); +}); diff --git a/e2e/src/fixtures.ts b/e2e/src/fixtures.ts index 9e311c896df11..4bfc6acedfef0 100644 --- a/e2e/src/fixtures.ts +++ b/e2e/src/fixtures.ts @@ -2,6 +2,7 @@ export const uuidDto = { invalid: 'invalid-uuid', // valid uuid v4 notFound: '00000000-0000-4000-a000-000000000000', + dummy: '00000000-0000-4000-a000-000000000000', }; const adminLoginDto = { diff --git a/mobile/lib/interfaces/sync.interface.dart b/mobile/lib/interfaces/sync.interface.dart new file mode 100644 index 0000000000000..b41120f50d52d --- /dev/null +++ b/mobile/lib/interfaces/sync.interface.dart @@ -0,0 +1,4 @@ +import 'package:immich_mobile/interfaces/database.interface.dart'; +import 'package:openapi/api.dart'; + +abstract interface class ISyncRepository implements IDatabaseRepository {} diff --git a/mobile/lib/interfaces/sync_api.interface.dart b/mobile/lib/interfaces/sync_api.interface.dart new file mode 100644 index 0000000000000..0f11fb52ecdd7 --- /dev/null +++ b/mobile/lib/interfaces/sync_api.interface.dart @@ -0,0 +1,12 @@ +import 'package:immich_mobile/models/sync/sync_event.model.dart'; +import 'package:openapi/api.dart'; + +abstract interface class ISyncApiRepository { + Stream> getChanges(SyncStreamDtoTypesEnum type); + + Future confirmChanges( + SyncStreamDtoTypesEnum type, + String id, + String timestamp, + ); +} diff --git a/mobile/lib/models/sync/sync_event.model.dart b/mobile/lib/models/sync/sync_event.model.dart new file mode 100644 index 0000000000000..5c294f95115e6 --- /dev/null +++ b/mobile/lib/models/sync/sync_event.model.dart @@ -0,0 +1,94 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first + +import 'dart:convert'; + +import 'package:openapi/api.dart'; + +class SyncEvent { + // enum + final SyncStreamDtoTypesEnum type; + + // enum + final SyncAction action; + + // dynamic + final dynamic data; + + // Acknowledge info + final String id; + final String timestamp; + + SyncEvent({ + required this.type, + required this.action, + required this.data, + required this.id, + required this.timestamp, + }); + + SyncEvent copyWith({ + SyncStreamDtoTypesEnum? type, + SyncAction? action, + dynamic data, + String? id, + String? timestamp, + }) { + return SyncEvent( + type: type ?? this.type, + action: action ?? this.action, + data: data ?? this.data, + id: id ?? this.id, + timestamp: timestamp ?? this.timestamp, + ); + } + + Map toMap() { + return { + 'type': type, + 'action': action, + 'data': data, + 'id': id, + 'timestamp': timestamp, + }; + } + + factory SyncEvent.fromMap(Map map) { + return SyncEvent( + type: SyncStreamDtoTypesEnum.values[map['type'] as int], + action: SyncAction.values[map['action'] as int], + data: map['data'] as dynamic, + id: map['id'] as String, + timestamp: map['timestamp'] as String, + ); + } + + String toJson() => json.encode(toMap()); + + factory SyncEvent.fromJson(String source) => + SyncEvent.fromMap(json.decode(source) as Map); + + @override + String toString() { + return 'SyncEvent(type: $type, action: $action, data: $data, id: $id, timestamp: $timestamp)'; + } + + @override + bool operator ==(covariant SyncEvent other) { + if (identical(this, other)) return true; + + return other.type == type && + other.action == action && + other.data == data && + other.id == id && + other.timestamp == timestamp; + } + + @override + int get hashCode { + return type.hashCode ^ + action.hashCode ^ + data.hashCode ^ + id.hashCode ^ + timestamp.hashCode; + } +} diff --git a/mobile/lib/pages/search/search.page.dart b/mobile/lib/pages/search/search.page.dart index 60e61da4cc5d5..6c82364676c66 100644 --- a/mobile/lib/pages/search/search.page.dart +++ b/mobile/lib/pages/search/search.page.dart @@ -13,6 +13,7 @@ import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/providers/search/paginated_search.provider.dart'; import 'package:immich_mobile/providers/search/search_input_focus.provider.dart'; import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart'; import 'package:immich_mobile/widgets/search/search_filter/camera_picker.dart'; import 'package:immich_mobile/widgets/search/search_filter/display_option_picker.dart'; @@ -674,11 +675,11 @@ class SearchEmptyContent extends StatelessWidget { } } -class QuickLinkList extends StatelessWidget { +class QuickLinkList extends ConsumerWidget { const QuickLinkList({super.key}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { return Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(20), @@ -717,6 +718,12 @@ class QuickLinkList extends StatelessWidget { isBottom: true, onTap: () => context.pushRoute(FavoritesRoute()), ), + QuickLink( + title: 'test'.tr(), + icon: Icons.favorite_border_rounded, + isBottom: true, + onTap: () => ref.read(syncServiceProvider).syncAssets(), + ), ], ), ); diff --git a/mobile/lib/repositories/sync.repository.dart b/mobile/lib/repositories/sync.repository.dart new file mode 100644 index 0000000000000..130c56d1c6b29 --- /dev/null +++ b/mobile/lib/repositories/sync.repository.dart @@ -0,0 +1,14 @@ +import 'package:immich_mobile/interfaces/sync.interface.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/database.repository.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +final syncRepositoryProvider = Provider( + (ref) => SyncRepository( + ref.watch(dbProvider), + ), +); + +class SyncRepository extends DatabaseRepository implements ISyncRepository { + SyncRepository(super.db); +} diff --git a/mobile/lib/repositories/sync_api.repository.dart b/mobile/lib/repositories/sync_api.repository.dart new file mode 100644 index 0000000000000..a71f080b5f411 --- /dev/null +++ b/mobile/lib/repositories/sync_api.repository.dart @@ -0,0 +1,165 @@ +import 'package:flutter/foundation.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/interfaces/sync_api.interface.dart'; +import 'package:immich_mobile/models/sync/sync_event.model.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/repositories/api.repository.dart'; +import 'package:openapi/api.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:http/http.dart' as http; +import 'dart:convert'; + +final syncApiRepositoryProvider = Provider( + (ref) => SyncApiRepository(ref.watch(apiServiceProvider).syncApi), +); + +class SyncApiRepository extends ApiRepository implements ISyncApiRepository { + // ignore: unused_field + final SyncApi _api; + + SyncApiRepository(this._api); + + @override + Stream> getChanges( + SyncStreamDtoTypesEnum type, + ) async* { + final batchSize = 1000; + final serverUrl = Store.get(StoreKey.serverUrl); + final accessToken = Store.get(StoreKey.accessToken); + + final url = Uri.parse('$serverUrl/sync/stream'); + final client = http.Client(); + final request = http.Request('POST', url); + final headers = { + 'Content-Type': 'application/json', + 'x-immich-user-token': accessToken, + }; + + request.headers.addAll(headers); + request.body = json.encode({ + "types": [type], + }); + String previousChunk = ''; + List lines = []; + + try { + final response = await client.send(request); + + await for (var chunk in response.stream.transform(utf8.decoder)) { + previousChunk += chunk; + final parts = previousChunk.split('\n'); + previousChunk = parts.removeLast(); + lines.addAll(parts); + + if (lines.length < batchSize) { + continue; + } + + yield await compute(_parseSyncReponse, lines); + lines.clear(); + } + } finally { + yield await compute(_parseSyncReponse, lines); + client.close(); + } + } + + @override + Future confirmChanges( + SyncStreamDtoTypesEnum type, + String id, + String timestamp, + ) async { + try { + switch (type) { + case SyncStreamDtoTypesEnum.asset: + await _api.ackSync( + SyncAcknowledgeDto( + asset: SyncCheckpointDto(id: id, timestamp: timestamp), + ), + ); + break; + + case SyncStreamDtoTypesEnum.album: + await _api.ackSync( + SyncAcknowledgeDto( + album: SyncCheckpointDto(id: id, timestamp: timestamp), + ), + ); + + case SyncStreamDtoTypesEnum.user: + await _api.ackSync( + SyncAcknowledgeDto( + user: SyncCheckpointDto(id: id, timestamp: timestamp), + ), + ); + + case SyncStreamDtoTypesEnum.partner: + await _api.ackSync( + SyncAcknowledgeDto( + partner: SyncCheckpointDto(id: id, timestamp: timestamp), + ), + ); + break; + + default: + break; + } + } catch (error) { + debugPrint("[acknowledge] Error acknowledging sync $error"); + } + } + + List _parseSyncReponse( + List lines, + ) { + final List data = []; + + for (var line in lines) { + try { + final type = SyncStreamDtoTypesEnum.fromJson(jsonDecode(line)['type'])!; + final action = SyncAction.fromJson(jsonDecode(line)['action']); + final dataJson = jsonDecode(line)['data']; + final id = jsonDecode(line)['ack']['id']; + final timestamp = jsonDecode(line)['ack']['timestamp']; + + switch (type) { + case SyncStreamDtoTypesEnum.asset: + if (action == SyncAction.upsert) { + final dto = AssetResponseDto.fromJson(dataJson)!; + final asset = Asset.remote(dto); + + data.add( + SyncEvent( + type: type, + action: SyncAction.upsert, + data: asset, + id: id, + timestamp: timestamp, + ), + ); + } else if (action == SyncAction.delete) { + data.add( + SyncEvent( + type: type, + action: SyncAction.delete, + data: dataJson.toString(), + id: id, + timestamp: timestamp, + ), + ); + } + break; + + default: + break; + } + } catch (error) { + debugPrint("[_parseSyncReponse] Error parsing json $error"); + } + } + + return data; + } +} diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index 515023d163f1c..e8bbbfe85593d 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -5,7 +5,6 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/utils/url_helper.dart'; -import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; import 'package:http/http.dart'; @@ -38,7 +37,6 @@ class ApiService implements Authentication { } } String? _accessToken; - final _log = Logger("ApiService"); setEndpoint(String endpoint) { _apiClient = ApiClient(basePath: endpoint, authentication: this); @@ -108,12 +106,7 @@ class ApiService implements Authentication { return false; } on SocketException catch (_) { return false; - } catch (error, stackTrace) { - _log.severe( - "Error while checking server availability", - error, - stackTrace, - ); + } catch (error) { return false; } return true; diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index 3959e2a6edc2b..8fa52835a6dbb 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -23,6 +23,8 @@ import 'package:immich_mobile/repositories/etag.repository.dart'; import 'package:immich_mobile/repositories/exif_info.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/repositories/partner_api.repository.dart'; +import 'package:immich_mobile/repositories/sync.repository.dart'; +import 'package:immich_mobile/repositories/sync_api.repository.dart'; import 'package:immich_mobile/repositories/user.repository.dart'; import 'package:immich_mobile/repositories/user_api.repository.dart'; import 'package:immich_mobile/services/album.service.dart'; @@ -371,6 +373,8 @@ class BackgroundService { BackupRepository backupRepository = BackupRepository(db); ExifInfoRepository exifInfoRepository = ExifInfoRepository(db); ETagRepository eTagRepository = ETagRepository(db); + SyncApiRepository syncApiRepository = SyncApiRepository(apiService.syncApi); + SyncRepository syncRepository = SyncRepository(db); AlbumMediaRepository albumMediaRepository = AlbumMediaRepository(); FileMediaRepository fileMediaRepository = FileMediaRepository(); AssetMediaRepository assetMediaRepository = AssetMediaRepository(); @@ -395,6 +399,8 @@ class BackgroundService { exifInfoRepository, userRepository, eTagRepository, + syncRepository, + syncApiRepository, ); UserService userService = UserService( partnerApiRepository, diff --git a/mobile/lib/services/sync.service.dart b/mobile/lib/services/sync.service.dart index f1a6e9b0d7365..8fe3ba9a473f4 100644 --- a/mobile/lib/services/sync.service.dart +++ b/mobile/lib/services/sync.service.dart @@ -1,6 +1,8 @@ import 'dart:async'; +import 'package:async/async.dart'; import 'package:collection/collection.dart'; +import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; @@ -12,6 +14,8 @@ import 'package:immich_mobile/interfaces/album_media.interface.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/etag.interface.dart'; import 'package:immich_mobile/interfaces/exif_info.interface.dart'; +import 'package:immich_mobile/interfaces/sync.interface.dart'; +import 'package:immich_mobile/interfaces/sync_api.interface.dart'; import 'package:immich_mobile/interfaces/user.interface.dart'; import 'package:immich_mobile/repositories/album.repository.dart'; import 'package:immich_mobile/repositories/album_api.repository.dart'; @@ -19,6 +23,8 @@ import 'package:immich_mobile/repositories/album_media.repository.dart'; import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/repositories/etag.repository.dart'; import 'package:immich_mobile/repositories/exif_info.repository.dart'; +import 'package:immich_mobile/repositories/sync.repository.dart'; +import 'package:immich_mobile/repositories/sync_api.repository.dart'; import 'package:immich_mobile/repositories/user.repository.dart'; import 'package:immich_mobile/services/entity.service.dart'; import 'package:immich_mobile/services/hash.service.dart'; @@ -26,7 +32,9 @@ import 'package:immich_mobile/utils/async_mutex.dart'; import 'package:immich_mobile/extensions/collection_extensions.dart'; import 'package:immich_mobile/utils/datetime_comparison.dart'; import 'package:immich_mobile/utils/diff.dart'; +import 'package:immich_mobile/utils/hooks/timer_hook.dart'; import 'package:logging/logging.dart'; +import 'package:openapi/api.dart'; final syncServiceProvider = Provider( (ref) => SyncService( @@ -39,6 +47,8 @@ final syncServiceProvider = Provider( ref.watch(exifInfoRepositoryProvider), ref.watch(userRepositoryProvider), ref.watch(etagRepositoryProvider), + ref.watch(syncRepositoryProvider), + ref.watch(syncApiRepositoryProvider), ), ); @@ -52,6 +62,8 @@ class SyncService { final IExifInfoRepository _exifInfoRepository; final IUserRepository _userRepository; final IETagRepository _eTagRepository; + final ISyncRepository _syncRepository; + final ISyncApiRepository _syncApiRepository; final AsyncMutex _lock = AsyncMutex(); final Logger _log = Logger('SyncService'); @@ -65,8 +77,93 @@ class SyncService { this._exifInfoRepository, this._userRepository, this._eTagRepository, + this._syncRepository, + this._syncApiRepository, ); + Future syncAssets() async { + try { + final batchSize = 1000; + final List toUpsert = []; + final List toDelete = []; + String ackTimestamp = ""; + String ackId = ""; + + final eventStream = + _syncApiRepository.getChanges(SyncStreamDtoTypesEnum.asset); + + await for (final event in eventStream) { + for (final e in event) { + ackTimestamp = e.timestamp; + ackId = e.id; + + if (e.action == SyncAction.upsert) { + toUpsert.add(e.data as Asset); + } + + if (e.action == SyncAction.delete) { + toDelete.add(e.data as String); + } + + if (toUpsert.length >= batchSize) { + await _upsertAssets(toUpsert); + toUpsert.clear(); + await _confirmAssetsChanges(ackId, ackTimestamp); + } + + if (toDelete.length >= batchSize) { + await _deleteAssets(toDelete); + toDelete.clear(); + await _confirmAssetsChanges(ackId, ackTimestamp); + } + } + + // Process any remaining events + if (toUpsert.isNotEmpty) { + await _upsertAssets(toUpsert); + toUpsert.clear(); + await _confirmAssetsChanges(ackId, ackTimestamp); + } + + if (toDelete.isNotEmpty) { + await _deleteAssets(toDelete); + toDelete.clear(); + await _confirmAssetsChanges(ackId, ackTimestamp); + } + } + } catch (error, stackTrace) { + _log.severe("Error syncing assets", error, stackTrace); + } + } + + Future _confirmAssetsChanges(String id, String timestamp) async { + await _syncApiRepository.confirmChanges( + SyncStreamDtoTypesEnum.asset, + id, + timestamp, + ); + } + + Future _upsertAssets( + List toUpsert, + ) async { + final (updateAssets, newAssets) = await _getAssetsFromDb(toUpsert); + + if (updateAssets.isNotEmpty) { + await _assetRepository.updateAll(updateAssets); + } + + if (newAssets.isNotEmpty) { + await upsertAssetsWithExif(newAssets); + } + } + + Future _deleteAssets( + List toDelete, + ) async { + await _assetRepository.deleteAllByRemoteId(toDelete); + } + // public methods: /// Syncs users from the server to the local database @@ -746,6 +843,35 @@ class SyncService { return (existing, toUpsert); } + /// !TODO: to replace _linkWithExistingFromDb above + Future<(List updateAssets, List newAssets)> _getAssetsFromDb( + List assets, + ) async { + if (assets.isEmpty) return ([].cast(), [].cast()); + + final List inDb = await _assetRepository.getAllByOwnerIdChecksum( + assets.map((a) => a.ownerId).toInt64List(), + assets.map((a) => a.checksum).toList(growable: false), + ); + assert(inDb.length == assets.length); + + final List updateAssets = []; + final List newAssets = []; + + for (int i = 0; i < assets.length; i++) { + final Asset? b = inDb[i]; + if (b == null) { + newAssets.add(assets[i]); + continue; + } else { + updateAssets.add(b); + } + } + + assert(updateAssets.length + newAssets.length == assets.length); + return (updateAssets, newAssets); + } + /// Inserts or updates the assets in the database with their ExifInfo (if any) Future upsertAssetsWithExif(List assets) async { if (assets.isEmpty) return; @@ -778,7 +904,7 @@ class SyncService { } } else if (a.id != b.id) { _log.warning( - "Trying to insert another asset with the same checksum+owner. In DB:\n$b\nTo insert:\n$a", + "Trying to insert another asset with the same checksum+owner", ); } } diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index c3bc3b264c99d..86181471ad647 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -201,8 +201,10 @@ Class | Method | HTTP request | Description *StacksApi* | [**getStack**](doc//StacksApi.md#getstack) | **GET** /stacks/{id} | *StacksApi* | [**searchStacks**](doc//StacksApi.md#searchstacks) | **GET** /stacks | *StacksApi* | [**updateStack**](doc//StacksApi.md#updatestack) | **PUT** /stacks/{id} | +*SyncApi* | [**ackSync**](doc//SyncApi.md#acksync) | **POST** /sync/acknowledge | *SyncApi* | [**getDeltaSync**](doc//SyncApi.md#getdeltasync) | **POST** /sync/delta-sync | *SyncApi* | [**getFullSyncForUser**](doc//SyncApi.md#getfullsyncforuser) | **POST** /sync/full-sync | +*SyncApi* | [**getSyncStream**](doc//SyncApi.md#getsyncstream) | **POST** /sync/stream | *SystemConfigApi* | [**getConfig**](doc//SystemConfigApi.md#getconfig) | **GET** /system-config | *SystemConfigApi* | [**getConfigDefaults**](doc//SystemConfigApi.md#getconfigdefaults) | **GET** /system-config/defaults | *SystemConfigApi* | [**getStorageTemplateOptions**](doc//SystemConfigApi.md#getstoragetemplateoptions) | **GET** /system-config/storage-template-options | @@ -259,6 +261,7 @@ Class | Method | HTTP request | Description - [ActivityStatisticsResponseDto](doc//ActivityStatisticsResponseDto.md) - [AddUsersDto](doc//AddUsersDto.md) - [AdminOnboardingUpdateDto](doc//AdminOnboardingUpdateDto.md) + - [AlbumAssetResponseDto](doc//AlbumAssetResponseDto.md) - [AlbumResponseDto](doc//AlbumResponseDto.md) - [AlbumStatisticsResponseDto](doc//AlbumStatisticsResponseDto.md) - [AlbumUserAddDto](doc//AlbumUserAddDto.md) @@ -413,6 +416,13 @@ Class | Method | HTTP request | Description - [StackCreateDto](doc//StackCreateDto.md) - [StackResponseDto](doc//StackResponseDto.md) - [StackUpdateDto](doc//StackUpdateDto.md) + - [SyncAcknowledgeDto](doc//SyncAcknowledgeDto.md) + - [SyncAction](doc//SyncAction.md) + - [SyncCheckpointDto](doc//SyncCheckpointDto.md) + - [SyncStreamDto](doc//SyncStreamDto.md) + - [SyncStreamResponseDto](doc//SyncStreamResponseDto.md) + - [SyncStreamResponseDtoData](doc//SyncStreamResponseDtoData.md) + - [SyncType](doc//SyncType.md) - [SystemConfigDto](doc//SystemConfigDto.md) - [SystemConfigFFmpegDto](doc//SystemConfigFFmpegDto.md) - [SystemConfigFacesDto](doc//SystemConfigFacesDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 6fb7478d04bf2..db52e96016b94 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -73,6 +73,7 @@ part 'model/activity_response_dto.dart'; part 'model/activity_statistics_response_dto.dart'; part 'model/add_users_dto.dart'; part 'model/admin_onboarding_update_dto.dart'; +part 'model/album_asset_response_dto.dart'; part 'model/album_response_dto.dart'; part 'model/album_statistics_response_dto.dart'; part 'model/album_user_add_dto.dart'; @@ -227,6 +228,13 @@ part 'model/source_type.dart'; part 'model/stack_create_dto.dart'; part 'model/stack_response_dto.dart'; part 'model/stack_update_dto.dart'; +part 'model/sync_acknowledge_dto.dart'; +part 'model/sync_action.dart'; +part 'model/sync_checkpoint_dto.dart'; +part 'model/sync_stream_dto.dart'; +part 'model/sync_stream_response_dto.dart'; +part 'model/sync_stream_response_dto_data.dart'; +part 'model/sync_type.dart'; part 'model/system_config_dto.dart'; part 'model/system_config_f_fmpeg_dto.dart'; part 'model/system_config_faces_dto.dart'; diff --git a/mobile/openapi/lib/api/sync_api.dart b/mobile/openapi/lib/api/sync_api.dart index f94eb88081a15..0e1d6e2dd5fb2 100644 --- a/mobile/openapi/lib/api/sync_api.dart +++ b/mobile/openapi/lib/api/sync_api.dart @@ -16,6 +16,45 @@ class SyncApi { final ApiClient apiClient; + /// Performs an HTTP 'POST /sync/acknowledge' operation and returns the [Response]. + /// Parameters: + /// + /// * [SyncAcknowledgeDto] syncAcknowledgeDto (required): + Future ackSyncWithHttpInfo(SyncAcknowledgeDto syncAcknowledgeDto,) async { + // ignore: prefer_const_declarations + final path = r'/sync/acknowledge'; + + // ignore: prefer_final_locals + Object? postBody = syncAcknowledgeDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [SyncAcknowledgeDto] syncAcknowledgeDto (required): + Future ackSync(SyncAcknowledgeDto syncAcknowledgeDto,) async { + final response = await ackSyncWithHttpInfo(syncAcknowledgeDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + /// Performs an HTTP 'POST /sync/delta-sync' operation and returns the [Response]. /// Parameters: /// @@ -112,4 +151,54 @@ class SyncApi { } return null; } + + /// Performs an HTTP 'POST /sync/stream' operation and returns the [Response]. + /// Parameters: + /// + /// * [SyncStreamDto] syncStreamDto (required): + Future getSyncStreamWithHttpInfo(SyncStreamDto syncStreamDto,) async { + // ignore: prefer_const_declarations + final path = r'/sync/stream'; + + // ignore: prefer_final_locals + Object? postBody = syncStreamDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [SyncStreamDto] syncStreamDto (required): + Future?> getSyncStream(SyncStreamDto syncStreamDto,) async { + final response = await getSyncStreamWithHttpInfo(syncStreamDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index c1025b0bd4820..0fca525553906 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -200,6 +200,8 @@ class ApiClient { return AddUsersDto.fromJson(value); case 'AdminOnboardingUpdateDto': return AdminOnboardingUpdateDto.fromJson(value); + case 'AlbumAssetResponseDto': + return AlbumAssetResponseDto.fromJson(value); case 'AlbumResponseDto': return AlbumResponseDto.fromJson(value); case 'AlbumStatisticsResponseDto': @@ -508,6 +510,20 @@ class ApiClient { return StackResponseDto.fromJson(value); case 'StackUpdateDto': return StackUpdateDto.fromJson(value); + case 'SyncAcknowledgeDto': + return SyncAcknowledgeDto.fromJson(value); + case 'SyncAction': + return SyncActionTypeTransformer().decode(value); + case 'SyncCheckpointDto': + return SyncCheckpointDto.fromJson(value); + case 'SyncStreamDto': + return SyncStreamDto.fromJson(value); + case 'SyncStreamResponseDto': + return SyncStreamResponseDto.fromJson(value); + case 'SyncStreamResponseDtoData': + return SyncStreamResponseDtoData.fromJson(value); + case 'SyncType': + return SyncTypeTypeTransformer().decode(value); case 'SystemConfigDto': return SystemConfigDto.fromJson(value); case 'SystemConfigFFmpegDto': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index b7c6ad5e010d3..f28b4415d38ab 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -130,6 +130,12 @@ String parameterToString(dynamic value) { if (value is SourceType) { return SourceTypeTypeTransformer().encode(value).toString(); } + if (value is SyncAction) { + return SyncActionTypeTransformer().encode(value).toString(); + } + if (value is SyncType) { + return SyncTypeTypeTransformer().encode(value).toString(); + } if (value is TimeBucketSize) { return TimeBucketSizeTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/album_asset_response_dto.dart b/mobile/openapi/lib/model/album_asset_response_dto.dart new file mode 100644 index 0000000000000..5f78c79f04789 --- /dev/null +++ b/mobile/openapi/lib/model/album_asset_response_dto.dart @@ -0,0 +1,107 @@ +// +// 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 AlbumAssetResponseDto { + /// Returns a new [AlbumAssetResponseDto] instance. + AlbumAssetResponseDto({ + required this.albumId, + required this.assetId, + }); + + String albumId; + + String assetId; + + @override + bool operator ==(Object other) => identical(this, other) || other is AlbumAssetResponseDto && + other.albumId == albumId && + other.assetId == assetId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (albumId.hashCode) + + (assetId.hashCode); + + @override + String toString() => 'AlbumAssetResponseDto[albumId=$albumId, assetId=$assetId]'; + + Map toJson() { + final json = {}; + json[r'albumId'] = this.albumId; + json[r'assetId'] = this.assetId; + return json; + } + + /// Returns a new [AlbumAssetResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AlbumAssetResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AlbumAssetResponseDto"); + if (value is Map) { + final json = value.cast(); + + return AlbumAssetResponseDto( + albumId: mapValueOfType(json, r'albumId')!, + assetId: mapValueOfType(json, r'assetId')!, + ); + } + 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 = AlbumAssetResponseDto.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 = AlbumAssetResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AlbumAssetResponseDto-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] = AlbumAssetResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'albumId', + 'assetId', + }; +} + diff --git a/mobile/openapi/lib/model/sync_acknowledge_dto.dart b/mobile/openapi/lib/model/sync_acknowledge_dto.dart new file mode 100644 index 0000000000000..b5b7678d862b4 --- /dev/null +++ b/mobile/openapi/lib/model/sync_acknowledge_dto.dart @@ -0,0 +1,329 @@ +// +// 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 SyncAcknowledgeDto { + /// Returns a new [SyncAcknowledgeDto] instance. + SyncAcknowledgeDto({ + this.activity, + this.album, + this.albumAsset, + this.albumUser, + this.asset, + this.assetAlbum, + this.assetPartner, + this.memory, + this.partner, + this.person, + this.sharedLink, + this.stack, + this.tag, + this.user, + }); + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + SyncCheckpointDto? activity; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + SyncCheckpointDto? album; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + SyncCheckpointDto? albumAsset; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + SyncCheckpointDto? albumUser; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + SyncCheckpointDto? asset; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + SyncCheckpointDto? assetAlbum; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + SyncCheckpointDto? assetPartner; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + SyncCheckpointDto? memory; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + SyncCheckpointDto? partner; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + SyncCheckpointDto? person; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + SyncCheckpointDto? sharedLink; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + SyncCheckpointDto? stack; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + SyncCheckpointDto? tag; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + SyncCheckpointDto? user; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncAcknowledgeDto && + other.activity == activity && + other.album == album && + other.albumAsset == albumAsset && + other.albumUser == albumUser && + other.asset == asset && + other.assetAlbum == assetAlbum && + other.assetPartner == assetPartner && + other.memory == memory && + other.partner == partner && + other.person == person && + other.sharedLink == sharedLink && + other.stack == stack && + other.tag == tag && + other.user == user; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (activity == null ? 0 : activity!.hashCode) + + (album == null ? 0 : album!.hashCode) + + (albumAsset == null ? 0 : albumAsset!.hashCode) + + (albumUser == null ? 0 : albumUser!.hashCode) + + (asset == null ? 0 : asset!.hashCode) + + (assetAlbum == null ? 0 : assetAlbum!.hashCode) + + (assetPartner == null ? 0 : assetPartner!.hashCode) + + (memory == null ? 0 : memory!.hashCode) + + (partner == null ? 0 : partner!.hashCode) + + (person == null ? 0 : person!.hashCode) + + (sharedLink == null ? 0 : sharedLink!.hashCode) + + (stack == null ? 0 : stack!.hashCode) + + (tag == null ? 0 : tag!.hashCode) + + (user == null ? 0 : user!.hashCode); + + @override + String toString() => 'SyncAcknowledgeDto[activity=$activity, album=$album, albumAsset=$albumAsset, albumUser=$albumUser, asset=$asset, assetAlbum=$assetAlbum, assetPartner=$assetPartner, memory=$memory, partner=$partner, person=$person, sharedLink=$sharedLink, stack=$stack, tag=$tag, user=$user]'; + + Map toJson() { + final json = {}; + if (this.activity != null) { + json[r'activity'] = this.activity; + } else { + // json[r'activity'] = null; + } + if (this.album != null) { + json[r'album'] = this.album; + } else { + // json[r'album'] = null; + } + if (this.albumAsset != null) { + json[r'albumAsset'] = this.albumAsset; + } else { + // json[r'albumAsset'] = null; + } + if (this.albumUser != null) { + json[r'albumUser'] = this.albumUser; + } else { + // json[r'albumUser'] = null; + } + if (this.asset != null) { + json[r'asset'] = this.asset; + } else { + // json[r'asset'] = null; + } + if (this.assetAlbum != null) { + json[r'assetAlbum'] = this.assetAlbum; + } else { + // json[r'assetAlbum'] = null; + } + if (this.assetPartner != null) { + json[r'assetPartner'] = this.assetPartner; + } else { + // json[r'assetPartner'] = null; + } + if (this.memory != null) { + json[r'memory'] = this.memory; + } else { + // json[r'memory'] = null; + } + if (this.partner != null) { + json[r'partner'] = this.partner; + } else { + // json[r'partner'] = null; + } + if (this.person != null) { + json[r'person'] = this.person; + } else { + // json[r'person'] = null; + } + if (this.sharedLink != null) { + json[r'sharedLink'] = this.sharedLink; + } else { + // json[r'sharedLink'] = null; + } + if (this.stack != null) { + json[r'stack'] = this.stack; + } else { + // json[r'stack'] = null; + } + if (this.tag != null) { + json[r'tag'] = this.tag; + } else { + // json[r'tag'] = null; + } + if (this.user != null) { + json[r'user'] = this.user; + } else { + // json[r'user'] = null; + } + return json; + } + + /// Returns a new [SyncAcknowledgeDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncAcknowledgeDto? fromJson(dynamic value) { + upgradeDto(value, "SyncAcknowledgeDto"); + if (value is Map) { + final json = value.cast(); + + return SyncAcknowledgeDto( + activity: SyncCheckpointDto.fromJson(json[r'activity']), + album: SyncCheckpointDto.fromJson(json[r'album']), + albumAsset: SyncCheckpointDto.fromJson(json[r'albumAsset']), + albumUser: SyncCheckpointDto.fromJson(json[r'albumUser']), + asset: SyncCheckpointDto.fromJson(json[r'asset']), + assetAlbum: SyncCheckpointDto.fromJson(json[r'assetAlbum']), + assetPartner: SyncCheckpointDto.fromJson(json[r'assetPartner']), + memory: SyncCheckpointDto.fromJson(json[r'memory']), + partner: SyncCheckpointDto.fromJson(json[r'partner']), + person: SyncCheckpointDto.fromJson(json[r'person']), + sharedLink: SyncCheckpointDto.fromJson(json[r'sharedLink']), + stack: SyncCheckpointDto.fromJson(json[r'stack']), + tag: SyncCheckpointDto.fromJson(json[r'tag']), + user: SyncCheckpointDto.fromJson(json[r'user']), + ); + } + 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 = SyncAcknowledgeDto.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 = SyncAcknowledgeDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncAcknowledgeDto-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] = SyncAcknowledgeDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + }; +} + diff --git a/mobile/openapi/lib/model/sync_action.dart b/mobile/openapi/lib/model/sync_action.dart new file mode 100644 index 0000000000000..64032007b1fc4 --- /dev/null +++ b/mobile/openapi/lib/model/sync_action.dart @@ -0,0 +1,85 @@ +// +// 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 SyncAction { + /// Instantiate a new enum with the provided [value]. + const SyncAction._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const upsert = SyncAction._(r'upsert'); + static const delete = SyncAction._(r'delete'); + + /// List of all possible values in this [enum][SyncAction]. + static const values = [ + upsert, + delete, + ]; + + static SyncAction? fromJson(dynamic value) => SyncActionTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SyncAction.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [SyncAction] to String, +/// and [decode] dynamic data back to [SyncAction]. +class SyncActionTypeTransformer { + factory SyncActionTypeTransformer() => _instance ??= const SyncActionTypeTransformer._(); + + const SyncActionTypeTransformer._(); + + String encode(SyncAction data) => data.value; + + /// Decodes a [dynamic value][data] to a SyncAction. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + SyncAction? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'upsert': return SyncAction.upsert; + case r'delete': return SyncAction.delete; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [SyncActionTypeTransformer] instance. + static SyncActionTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/sync_checkpoint_dto.dart b/mobile/openapi/lib/model/sync_checkpoint_dto.dart new file mode 100644 index 0000000000000..e09f20fd6ad15 --- /dev/null +++ b/mobile/openapi/lib/model/sync_checkpoint_dto.dart @@ -0,0 +1,107 @@ +// +// 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 SyncCheckpointDto { + /// Returns a new [SyncCheckpointDto] instance. + SyncCheckpointDto({ + required this.id, + required this.timestamp, + }); + + String id; + + String timestamp; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncCheckpointDto && + other.id == id && + other.timestamp == timestamp; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (id.hashCode) + + (timestamp.hashCode); + + @override + String toString() => 'SyncCheckpointDto[id=$id, timestamp=$timestamp]'; + + Map toJson() { + final json = {}; + json[r'id'] = this.id; + json[r'timestamp'] = this.timestamp; + return json; + } + + /// Returns a new [SyncCheckpointDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncCheckpointDto? fromJson(dynamic value) { + upgradeDto(value, "SyncCheckpointDto"); + if (value is Map) { + final json = value.cast(); + + return SyncCheckpointDto( + id: mapValueOfType(json, r'id')!, + timestamp: mapValueOfType(json, r'timestamp')!, + ); + } + 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 = SyncCheckpointDto.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 = SyncCheckpointDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncCheckpointDto-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] = SyncCheckpointDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'id', + 'timestamp', + }; +} + diff --git a/mobile/openapi/lib/model/sync_stream_dto.dart b/mobile/openapi/lib/model/sync_stream_dto.dart new file mode 100644 index 0000000000000..26e16a6ddb310 --- /dev/null +++ b/mobile/openapi/lib/model/sync_stream_dto.dart @@ -0,0 +1,209 @@ +// +// 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 SyncStreamDto { + /// Returns a new [SyncStreamDto] instance. + SyncStreamDto({ + this.types = const [], + }); + + List types; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncStreamDto && + _deepEquality.equals(other.types, types); + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (types.hashCode); + + @override + String toString() => 'SyncStreamDto[types=$types]'; + + Map toJson() { + final json = {}; + json[r'types'] = this.types; + return json; + } + + /// Returns a new [SyncStreamDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncStreamDto? fromJson(dynamic value) { + upgradeDto(value, "SyncStreamDto"); + if (value is Map) { + final json = value.cast(); + + return SyncStreamDto( + types: SyncStreamDtoTypesEnum.listFromJson(json[r'types']), + ); + } + 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 = SyncStreamDto.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 = SyncStreamDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncStreamDto-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] = SyncStreamDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'types', + }; +} + + +class SyncStreamDtoTypesEnum { + /// Instantiate a new enum with the provided [value]. + const SyncStreamDtoTypesEnum._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const asset = SyncStreamDtoTypesEnum._(r'asset'); + static const assetPeriodPartner = SyncStreamDtoTypesEnum._(r'asset.partner'); + static const assetAlbum = SyncStreamDtoTypesEnum._(r'assetAlbum'); + static const album = SyncStreamDtoTypesEnum._(r'album'); + static const albumAsset = SyncStreamDtoTypesEnum._(r'albumAsset'); + static const albumUser = SyncStreamDtoTypesEnum._(r'albumUser'); + static const activity = SyncStreamDtoTypesEnum._(r'activity'); + static const memory = SyncStreamDtoTypesEnum._(r'memory'); + static const partner = SyncStreamDtoTypesEnum._(r'partner'); + static const person = SyncStreamDtoTypesEnum._(r'person'); + static const sharedLink = SyncStreamDtoTypesEnum._(r'sharedLink'); + static const stack = SyncStreamDtoTypesEnum._(r'stack'); + static const tag = SyncStreamDtoTypesEnum._(r'tag'); + static const user = SyncStreamDtoTypesEnum._(r'user'); + + /// List of all possible values in this [enum][SyncStreamDtoTypesEnum]. + static const values = [ + asset, + assetPeriodPartner, + assetAlbum, + album, + albumAsset, + albumUser, + activity, + memory, + partner, + person, + sharedLink, + stack, + tag, + user, + ]; + + static SyncStreamDtoTypesEnum? fromJson(dynamic value) => SyncStreamDtoTypesEnumTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SyncStreamDtoTypesEnum.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [SyncStreamDtoTypesEnum] to String, +/// and [decode] dynamic data back to [SyncStreamDtoTypesEnum]. +class SyncStreamDtoTypesEnumTypeTransformer { + factory SyncStreamDtoTypesEnumTypeTransformer() => _instance ??= const SyncStreamDtoTypesEnumTypeTransformer._(); + + const SyncStreamDtoTypesEnumTypeTransformer._(); + + String encode(SyncStreamDtoTypesEnum data) => data.value; + + /// Decodes a [dynamic value][data] to a SyncStreamDtoTypesEnum. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + SyncStreamDtoTypesEnum? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'asset': return SyncStreamDtoTypesEnum.asset; + case r'asset.partner': return SyncStreamDtoTypesEnum.assetPeriodPartner; + case r'assetAlbum': return SyncStreamDtoTypesEnum.assetAlbum; + case r'album': return SyncStreamDtoTypesEnum.album; + case r'albumAsset': return SyncStreamDtoTypesEnum.albumAsset; + case r'albumUser': return SyncStreamDtoTypesEnum.albumUser; + case r'activity': return SyncStreamDtoTypesEnum.activity; + case r'memory': return SyncStreamDtoTypesEnum.memory; + case r'partner': return SyncStreamDtoTypesEnum.partner; + case r'person': return SyncStreamDtoTypesEnum.person; + case r'sharedLink': return SyncStreamDtoTypesEnum.sharedLink; + case r'stack': return SyncStreamDtoTypesEnum.stack; + case r'tag': return SyncStreamDtoTypesEnum.tag; + case r'user': return SyncStreamDtoTypesEnum.user; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [SyncStreamDtoTypesEnumTypeTransformer] instance. + static SyncStreamDtoTypesEnumTypeTransformer? _instance; +} + + diff --git a/mobile/openapi/lib/model/sync_stream_response_dto.dart b/mobile/openapi/lib/model/sync_stream_response_dto.dart new file mode 100644 index 0000000000000..947e13d1e9754 --- /dev/null +++ b/mobile/openapi/lib/model/sync_stream_response_dto.dart @@ -0,0 +1,115 @@ +// +// 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 SyncStreamResponseDto { + /// Returns a new [SyncStreamResponseDto] instance. + SyncStreamResponseDto({ + required this.action, + required this.data, + required this.type, + }); + + SyncAction action; + + SyncStreamResponseDtoData data; + + SyncType type; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncStreamResponseDto && + other.action == action && + other.data == data && + other.type == type; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (action.hashCode) + + (data.hashCode) + + (type.hashCode); + + @override + String toString() => 'SyncStreamResponseDto[action=$action, data=$data, type=$type]'; + + Map toJson() { + final json = {}; + json[r'action'] = this.action; + json[r'data'] = this.data; + json[r'type'] = this.type; + return json; + } + + /// Returns a new [SyncStreamResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncStreamResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SyncStreamResponseDto"); + if (value is Map) { + final json = value.cast(); + + return SyncStreamResponseDto( + action: SyncAction.fromJson(json[r'action'])!, + data: SyncStreamResponseDtoData.fromJson(json[r'data'])!, + type: SyncType.fromJson(json[r'type'])!, + ); + } + 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 = SyncStreamResponseDto.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 = SyncStreamResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncStreamResponseDto-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] = SyncStreamResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'action', + 'data', + 'type', + }; +} + diff --git a/mobile/openapi/lib/model/sync_stream_response_dto_data.dart b/mobile/openapi/lib/model/sync_stream_response_dto_data.dart new file mode 100644 index 0000000000000..09f541e372acf --- /dev/null +++ b/mobile/openapi/lib/model/sync_stream_response_dto_data.dart @@ -0,0 +1,830 @@ +// +// 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 SyncStreamResponseDtoData { + /// Returns a new [SyncStreamResponseDtoData] instance. + SyncStreamResponseDtoData({ + required this.checksum, + required this.deviceAssetId, + required this.deviceId, + this.duplicateId, + required this.duration, + this.exifInfo, + required this.fileCreatedAt, + required this.fileModifiedAt, + required this.hasMetadata, + required this.id, + required this.isArchived, + required this.isFavorite, + required this.isOffline, + required this.isTrashed, + this.libraryId, + this.livePhotoVideoId, + required this.localDateTime, + required this.originalFileName, + this.originalMimeType, + required this.originalPath, + required this.owner, + required this.ownerId, + this.people = const [], + this.resized, + this.smartInfo, + this.stack, + this.tags = const [], + required this.thumbhash, + required this.type, + this.unassignedFaces = const [], + required this.updatedAt, + required this.albumName, + required this.albumThumbnailAssetId, + this.albumUsers = const [], + required this.assetCount, + this.assets = const [], + required this.createdAt, + required this.description, + this.endDate, + required this.hasSharedLink, + required this.isActivityEnabled, + this.lastModifiedAssetTimestamp, + this.order, + required this.shared, + this.startDate, + required this.albumId, + required this.assetId, + this.comment, + required this.user, + required this.data, + this.deletedAt, + required this.isSaved, + required this.memoryAt, + this.seenAt, + required this.avatarColor, + required this.email, + this.inTimeline, + required this.name, + required this.profileChangedAt, + required this.profileImagePath, + required this.birthDate, + required this.isHidden, + required this.thumbnailPath, + this.album, + required this.allowDownload, + required this.allowUpload, + required this.expiresAt, + required this.key, + required this.password, + required this.showMetadata, + this.token, + required this.userId, + required this.primaryAssetId, + }); + + /// base64 encoded sha1 hash + String checksum; + + String deviceAssetId; + + String deviceId; + + String? duplicateId; + + String duration; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + ExifResponseDto? exifInfo; + + DateTime fileCreatedAt; + + DateTime fileModifiedAt; + + bool hasMetadata; + + String id; + + bool isArchived; + + bool isFavorite; + + bool isOffline; + + bool isTrashed; + + /// This property was deprecated in v1.106.0 + String? libraryId; + + String? livePhotoVideoId; + + DateTime localDateTime; + + String originalFileName; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? originalMimeType; + + String originalPath; + + UserResponseDto owner; + + String ownerId; + + List people; + + /// This property was deprecated in v1.113.0 + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? resized; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + SmartInfoResponseDto? smartInfo; + + AssetStackResponseDto? stack; + + List tags; + + String? thumbhash; + + SharedLinkType type; + + List unassignedFaces; + + /// This property was added in v1.107.0 + DateTime updatedAt; + + String albumName; + + String? albumThumbnailAssetId; + + List albumUsers; + + int assetCount; + + List assets; + + DateTime createdAt; + + String? description; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + DateTime? endDate; + + bool hasSharedLink; + + bool isActivityEnabled; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + DateTime? lastModifiedAssetTimestamp; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + AssetOrder? order; + + bool shared; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + DateTime? startDate; + + String albumId; + + String? assetId; + + String? comment; + + UserResponseDto user; + + OnThisDayDto data; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + DateTime? deletedAt; + + bool isSaved; + + DateTime memoryAt; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + DateTime? seenAt; + + UserAvatarColor avatarColor; + + String email; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? inTimeline; + + String name; + + DateTime profileChangedAt; + + String profileImagePath; + + DateTime? birthDate; + + bool isHidden; + + String thumbnailPath; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + AlbumResponseDto? album; + + bool allowDownload; + + bool allowUpload; + + DateTime? expiresAt; + + String key; + + String? password; + + bool showMetadata; + + String? token; + + String userId; + + String primaryAssetId; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncStreamResponseDtoData && + other.checksum == checksum && + other.deviceAssetId == deviceAssetId && + other.deviceId == deviceId && + other.duplicateId == duplicateId && + other.duration == duration && + other.exifInfo == exifInfo && + other.fileCreatedAt == fileCreatedAt && + other.fileModifiedAt == fileModifiedAt && + other.hasMetadata == hasMetadata && + other.id == id && + other.isArchived == isArchived && + other.isFavorite == isFavorite && + other.isOffline == isOffline && + other.isTrashed == isTrashed && + other.libraryId == libraryId && + other.livePhotoVideoId == livePhotoVideoId && + other.localDateTime == localDateTime && + other.originalFileName == originalFileName && + other.originalMimeType == originalMimeType && + other.originalPath == originalPath && + other.owner == owner && + other.ownerId == ownerId && + _deepEquality.equals(other.people, people) && + other.resized == resized && + other.smartInfo == smartInfo && + other.stack == stack && + _deepEquality.equals(other.tags, tags) && + other.thumbhash == thumbhash && + other.type == type && + _deepEquality.equals(other.unassignedFaces, unassignedFaces) && + other.updatedAt == updatedAt && + other.albumName == albumName && + other.albumThumbnailAssetId == albumThumbnailAssetId && + _deepEquality.equals(other.albumUsers, albumUsers) && + other.assetCount == assetCount && + _deepEquality.equals(other.assets, assets) && + other.createdAt == createdAt && + other.description == description && + other.endDate == endDate && + other.hasSharedLink == hasSharedLink && + other.isActivityEnabled == isActivityEnabled && + other.lastModifiedAssetTimestamp == lastModifiedAssetTimestamp && + other.order == order && + other.shared == shared && + other.startDate == startDate && + other.albumId == albumId && + other.assetId == assetId && + other.comment == comment && + other.user == user && + other.data == data && + other.deletedAt == deletedAt && + other.isSaved == isSaved && + other.memoryAt == memoryAt && + other.seenAt == seenAt && + other.avatarColor == avatarColor && + other.email == email && + other.inTimeline == inTimeline && + other.name == name && + other.profileChangedAt == profileChangedAt && + other.profileImagePath == profileImagePath && + other.birthDate == birthDate && + other.isHidden == isHidden && + other.thumbnailPath == thumbnailPath && + other.album == album && + other.allowDownload == allowDownload && + other.allowUpload == allowUpload && + other.expiresAt == expiresAt && + other.key == key && + other.password == password && + other.showMetadata == showMetadata && + other.token == token && + other.userId == userId && + other.primaryAssetId == primaryAssetId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (checksum.hashCode) + + (deviceAssetId.hashCode) + + (deviceId.hashCode) + + (duplicateId == null ? 0 : duplicateId!.hashCode) + + (duration.hashCode) + + (exifInfo == null ? 0 : exifInfo!.hashCode) + + (fileCreatedAt.hashCode) + + (fileModifiedAt.hashCode) + + (hasMetadata.hashCode) + + (id.hashCode) + + (isArchived.hashCode) + + (isFavorite.hashCode) + + (isOffline.hashCode) + + (isTrashed.hashCode) + + (libraryId == null ? 0 : libraryId!.hashCode) + + (livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) + + (localDateTime.hashCode) + + (originalFileName.hashCode) + + (originalMimeType == null ? 0 : originalMimeType!.hashCode) + + (originalPath.hashCode) + + (owner.hashCode) + + (ownerId.hashCode) + + (people.hashCode) + + (resized == null ? 0 : resized!.hashCode) + + (smartInfo == null ? 0 : smartInfo!.hashCode) + + (stack == null ? 0 : stack!.hashCode) + + (tags.hashCode) + + (thumbhash == null ? 0 : thumbhash!.hashCode) + + (type.hashCode) + + (unassignedFaces.hashCode) + + (updatedAt.hashCode) + + (albumName.hashCode) + + (albumThumbnailAssetId == null ? 0 : albumThumbnailAssetId!.hashCode) + + (albumUsers.hashCode) + + (assetCount.hashCode) + + (assets.hashCode) + + (createdAt.hashCode) + + (description == null ? 0 : description!.hashCode) + + (endDate == null ? 0 : endDate!.hashCode) + + (hasSharedLink.hashCode) + + (isActivityEnabled.hashCode) + + (lastModifiedAssetTimestamp == null ? 0 : lastModifiedAssetTimestamp!.hashCode) + + (order == null ? 0 : order!.hashCode) + + (shared.hashCode) + + (startDate == null ? 0 : startDate!.hashCode) + + (albumId.hashCode) + + (assetId == null ? 0 : assetId!.hashCode) + + (comment == null ? 0 : comment!.hashCode) + + (user.hashCode) + + (data.hashCode) + + (deletedAt == null ? 0 : deletedAt!.hashCode) + + (isSaved.hashCode) + + (memoryAt.hashCode) + + (seenAt == null ? 0 : seenAt!.hashCode) + + (avatarColor.hashCode) + + (email.hashCode) + + (inTimeline == null ? 0 : inTimeline!.hashCode) + + (name.hashCode) + + (profileChangedAt.hashCode) + + (profileImagePath.hashCode) + + (birthDate == null ? 0 : birthDate!.hashCode) + + (isHidden.hashCode) + + (thumbnailPath.hashCode) + + (album == null ? 0 : album!.hashCode) + + (allowDownload.hashCode) + + (allowUpload.hashCode) + + (expiresAt == null ? 0 : expiresAt!.hashCode) + + (key.hashCode) + + (password == null ? 0 : password!.hashCode) + + (showMetadata.hashCode) + + (token == null ? 0 : token!.hashCode) + + (userId.hashCode) + + (primaryAssetId.hashCode); + + @override + String toString() => 'SyncStreamResponseDtoData[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, smartInfo=$smartInfo, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt, albumName=$albumName, albumThumbnailAssetId=$albumThumbnailAssetId, albumUsers=$albumUsers, assetCount=$assetCount, assets=$assets, createdAt=$createdAt, description=$description, endDate=$endDate, hasSharedLink=$hasSharedLink, isActivityEnabled=$isActivityEnabled, lastModifiedAssetTimestamp=$lastModifiedAssetTimestamp, order=$order, shared=$shared, startDate=$startDate, albumId=$albumId, assetId=$assetId, comment=$comment, user=$user, data=$data, deletedAt=$deletedAt, isSaved=$isSaved, memoryAt=$memoryAt, seenAt=$seenAt, avatarColor=$avatarColor, email=$email, inTimeline=$inTimeline, name=$name, profileChangedAt=$profileChangedAt, profileImagePath=$profileImagePath, birthDate=$birthDate, isHidden=$isHidden, thumbnailPath=$thumbnailPath, album=$album, allowDownload=$allowDownload, allowUpload=$allowUpload, expiresAt=$expiresAt, key=$key, password=$password, showMetadata=$showMetadata, token=$token, userId=$userId, primaryAssetId=$primaryAssetId]'; + + Map toJson() { + final json = {}; + json[r'checksum'] = this.checksum; + json[r'deviceAssetId'] = this.deviceAssetId; + json[r'deviceId'] = this.deviceId; + if (this.duplicateId != null) { + json[r'duplicateId'] = this.duplicateId; + } else { + // json[r'duplicateId'] = null; + } + json[r'duration'] = this.duration; + if (this.exifInfo != null) { + json[r'exifInfo'] = this.exifInfo; + } else { + // json[r'exifInfo'] = null; + } + json[r'fileCreatedAt'] = this.fileCreatedAt.toUtc().toIso8601String(); + json[r'fileModifiedAt'] = this.fileModifiedAt.toUtc().toIso8601String(); + json[r'hasMetadata'] = this.hasMetadata; + json[r'id'] = this.id; + json[r'isArchived'] = this.isArchived; + json[r'isFavorite'] = this.isFavorite; + json[r'isOffline'] = this.isOffline; + json[r'isTrashed'] = this.isTrashed; + 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; + } + json[r'localDateTime'] = this.localDateTime.toUtc().toIso8601String(); + json[r'originalFileName'] = this.originalFileName; + if (this.originalMimeType != null) { + json[r'originalMimeType'] = this.originalMimeType; + } else { + // json[r'originalMimeType'] = null; + } + json[r'originalPath'] = this.originalPath; + json[r'owner'] = this.owner; + json[r'ownerId'] = this.ownerId; + json[r'people'] = this.people; + if (this.resized != null) { + json[r'resized'] = this.resized; + } else { + // json[r'resized'] = null; + } + if (this.smartInfo != null) { + json[r'smartInfo'] = this.smartInfo; + } else { + // json[r'smartInfo'] = null; + } + if (this.stack != null) { + json[r'stack'] = this.stack; + } else { + // json[r'stack'] = null; + } + json[r'tags'] = this.tags; + if (this.thumbhash != null) { + json[r'thumbhash'] = this.thumbhash; + } else { + // json[r'thumbhash'] = null; + } + json[r'type'] = this.type; + json[r'unassignedFaces'] = this.unassignedFaces; + json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String(); + json[r'albumName'] = this.albumName; + if (this.albumThumbnailAssetId != null) { + json[r'albumThumbnailAssetId'] = this.albumThumbnailAssetId; + } else { + // json[r'albumThumbnailAssetId'] = null; + } + json[r'albumUsers'] = this.albumUsers; + json[r'assetCount'] = this.assetCount; + json[r'assets'] = this.assets; + json[r'createdAt'] = this.createdAt.toUtc().toIso8601String(); + if (this.description != null) { + json[r'description'] = this.description; + } else { + // json[r'description'] = null; + } + if (this.endDate != null) { + json[r'endDate'] = this.endDate!.toUtc().toIso8601String(); + } else { + // json[r'endDate'] = null; + } + json[r'hasSharedLink'] = this.hasSharedLink; + json[r'isActivityEnabled'] = this.isActivityEnabled; + if (this.lastModifiedAssetTimestamp != null) { + json[r'lastModifiedAssetTimestamp'] = this.lastModifiedAssetTimestamp!.toUtc().toIso8601String(); + } else { + // json[r'lastModifiedAssetTimestamp'] = null; + } + if (this.order != null) { + json[r'order'] = this.order; + } else { + // json[r'order'] = null; + } + json[r'shared'] = this.shared; + if (this.startDate != null) { + json[r'startDate'] = this.startDate!.toUtc().toIso8601String(); + } else { + // json[r'startDate'] = null; + } + json[r'albumId'] = this.albumId; + if (this.assetId != null) { + json[r'assetId'] = this.assetId; + } else { + // json[r'assetId'] = null; + } + if (this.comment != null) { + json[r'comment'] = this.comment; + } else { + // json[r'comment'] = null; + } + json[r'user'] = this.user; + json[r'data'] = this.data; + if (this.deletedAt != null) { + json[r'deletedAt'] = this.deletedAt!.toUtc().toIso8601String(); + } else { + // json[r'deletedAt'] = null; + } + json[r'isSaved'] = this.isSaved; + json[r'memoryAt'] = this.memoryAt.toUtc().toIso8601String(); + if (this.seenAt != null) { + json[r'seenAt'] = this.seenAt!.toUtc().toIso8601String(); + } else { + // json[r'seenAt'] = null; + } + json[r'avatarColor'] = this.avatarColor; + json[r'email'] = this.email; + if (this.inTimeline != null) { + json[r'inTimeline'] = this.inTimeline; + } else { + // json[r'inTimeline'] = null; + } + json[r'name'] = this.name; + json[r'profileChangedAt'] = this.profileChangedAt.toUtc().toIso8601String(); + json[r'profileImagePath'] = this.profileImagePath; + if (this.birthDate != null) { + json[r'birthDate'] = _dateFormatter.format(this.birthDate!.toUtc()); + } else { + // json[r'birthDate'] = null; + } + json[r'isHidden'] = this.isHidden; + json[r'thumbnailPath'] = this.thumbnailPath; + if (this.album != null) { + json[r'album'] = this.album; + } else { + // json[r'album'] = null; + } + json[r'allowDownload'] = this.allowDownload; + json[r'allowUpload'] = this.allowUpload; + if (this.expiresAt != null) { + json[r'expiresAt'] = this.expiresAt!.toUtc().toIso8601String(); + } else { + // json[r'expiresAt'] = null; + } + json[r'key'] = this.key; + if (this.password != null) { + json[r'password'] = this.password; + } else { + // json[r'password'] = null; + } + json[r'showMetadata'] = this.showMetadata; + if (this.token != null) { + json[r'token'] = this.token; + } else { + // json[r'token'] = null; + } + json[r'userId'] = this.userId; + json[r'primaryAssetId'] = this.primaryAssetId; + return json; + } + + /// Returns a new [SyncStreamResponseDtoData] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncStreamResponseDtoData? fromJson(dynamic value) { + upgradeDto(value, "SyncStreamResponseDtoData"); + if (value is Map) { + final json = value.cast(); + + return SyncStreamResponseDtoData( + checksum: mapValueOfType(json, r'checksum')!, + deviceAssetId: mapValueOfType(json, r'deviceAssetId')!, + deviceId: mapValueOfType(json, r'deviceId')!, + duplicateId: mapValueOfType(json, r'duplicateId'), + duration: mapValueOfType(json, r'duration')!, + exifInfo: ExifResponseDto.fromJson(json[r'exifInfo']), + fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r'')!, + fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r'')!, + hasMetadata: mapValueOfType(json, r'hasMetadata')!, + id: mapValueOfType(json, r'id')!, + isArchived: mapValueOfType(json, r'isArchived')!, + isFavorite: mapValueOfType(json, r'isFavorite')!, + isOffline: mapValueOfType(json, r'isOffline')!, + isTrashed: mapValueOfType(json, r'isTrashed')!, + libraryId: mapValueOfType(json, r'libraryId'), + livePhotoVideoId: mapValueOfType(json, r'livePhotoVideoId'), + localDateTime: mapDateTime(json, r'localDateTime', r'')!, + originalFileName: mapValueOfType(json, r'originalFileName')!, + originalMimeType: mapValueOfType(json, r'originalMimeType'), + originalPath: mapValueOfType(json, r'originalPath')!, + owner: UserResponseDto.fromJson(json[r'owner'])!, + ownerId: mapValueOfType(json, r'ownerId')!, + people: PersonWithFacesResponseDto.listFromJson(json[r'people']), + resized: mapValueOfType(json, r'resized'), + smartInfo: SmartInfoResponseDto.fromJson(json[r'smartInfo']), + stack: AssetStackResponseDto.fromJson(json[r'stack']), + tags: TagResponseDto.listFromJson(json[r'tags']), + thumbhash: mapValueOfType(json, r'thumbhash'), + type: SharedLinkType.fromJson(json[r'type'])!, + unassignedFaces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'unassignedFaces']), + updatedAt: mapDateTime(json, r'updatedAt', r'')!, + albumName: mapValueOfType(json, r'albumName')!, + albumThumbnailAssetId: mapValueOfType(json, r'albumThumbnailAssetId'), + albumUsers: AlbumUserResponseDto.listFromJson(json[r'albumUsers']), + assetCount: mapValueOfType(json, r'assetCount')!, + assets: AssetResponseDto.listFromJson(json[r'assets']), + createdAt: mapDateTime(json, r'createdAt', r'')!, + description: mapValueOfType(json, r'description'), + endDate: mapDateTime(json, r'endDate', r''), + hasSharedLink: mapValueOfType(json, r'hasSharedLink')!, + isActivityEnabled: mapValueOfType(json, r'isActivityEnabled')!, + lastModifiedAssetTimestamp: mapDateTime(json, r'lastModifiedAssetTimestamp', r''), + order: AssetOrder.fromJson(json[r'order']), + shared: mapValueOfType(json, r'shared')!, + startDate: mapDateTime(json, r'startDate', r''), + albumId: mapValueOfType(json, r'albumId')!, + assetId: mapValueOfType(json, r'assetId'), + comment: mapValueOfType(json, r'comment'), + user: UserResponseDto.fromJson(json[r'user'])!, + data: OnThisDayDto.fromJson(json[r'data'])!, + deletedAt: mapDateTime(json, r'deletedAt', r''), + isSaved: mapValueOfType(json, r'isSaved')!, + memoryAt: mapDateTime(json, r'memoryAt', r'')!, + seenAt: mapDateTime(json, r'seenAt', r''), + avatarColor: UserAvatarColor.fromJson(json[r'avatarColor'])!, + email: mapValueOfType(json, r'email')!, + inTimeline: mapValueOfType(json, r'inTimeline'), + name: mapValueOfType(json, r'name')!, + profileChangedAt: mapDateTime(json, r'profileChangedAt', r'')!, + profileImagePath: mapValueOfType(json, r'profileImagePath')!, + birthDate: mapDateTime(json, r'birthDate', r''), + isHidden: mapValueOfType(json, r'isHidden')!, + thumbnailPath: mapValueOfType(json, r'thumbnailPath')!, + album: AlbumResponseDto.fromJson(json[r'album']), + allowDownload: mapValueOfType(json, r'allowDownload')!, + allowUpload: mapValueOfType(json, r'allowUpload')!, + expiresAt: mapDateTime(json, r'expiresAt', r''), + key: mapValueOfType(json, r'key')!, + password: mapValueOfType(json, r'password'), + showMetadata: mapValueOfType(json, r'showMetadata')!, + token: mapValueOfType(json, r'token'), + userId: mapValueOfType(json, r'userId')!, + primaryAssetId: mapValueOfType(json, r'primaryAssetId')!, + ); + } + 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 = SyncStreamResponseDtoData.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 = SyncStreamResponseDtoData.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncStreamResponseDtoData-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] = SyncStreamResponseDtoData.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'checksum', + 'deviceAssetId', + 'deviceId', + 'duration', + 'fileCreatedAt', + 'fileModifiedAt', + 'hasMetadata', + 'id', + 'isArchived', + 'isFavorite', + 'isOffline', + 'isTrashed', + 'localDateTime', + 'originalFileName', + 'originalPath', + 'owner', + 'ownerId', + 'thumbhash', + 'type', + 'updatedAt', + 'albumName', + 'albumThumbnailAssetId', + 'albumUsers', + 'assetCount', + 'assets', + 'createdAt', + 'description', + 'hasSharedLink', + 'isActivityEnabled', + 'shared', + 'albumId', + 'assetId', + 'user', + 'data', + 'isSaved', + 'memoryAt', + 'avatarColor', + 'email', + 'name', + 'profileChangedAt', + 'profileImagePath', + 'birthDate', + 'isHidden', + 'thumbnailPath', + 'allowDownload', + 'allowUpload', + 'expiresAt', + 'key', + 'password', + 'showMetadata', + 'userId', + 'primaryAssetId', + }; +} + diff --git a/mobile/openapi/lib/model/sync_type.dart b/mobile/openapi/lib/model/sync_type.dart new file mode 100644 index 0000000000000..173420ab41173 --- /dev/null +++ b/mobile/openapi/lib/model/sync_type.dart @@ -0,0 +1,121 @@ +// +// 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 SyncType { + /// Instantiate a new enum with the provided [value]. + const SyncType._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const asset = SyncType._(r'asset'); + static const assetPeriodPartner = SyncType._(r'asset.partner'); + static const assetAlbum = SyncType._(r'assetAlbum'); + static const album = SyncType._(r'album'); + static const albumAsset = SyncType._(r'albumAsset'); + static const albumUser = SyncType._(r'albumUser'); + static const activity = SyncType._(r'activity'); + static const memory = SyncType._(r'memory'); + static const partner = SyncType._(r'partner'); + static const person = SyncType._(r'person'); + static const sharedLink = SyncType._(r'sharedLink'); + static const stack = SyncType._(r'stack'); + static const tag = SyncType._(r'tag'); + static const user = SyncType._(r'user'); + + /// List of all possible values in this [enum][SyncType]. + static const values = [ + asset, + assetPeriodPartner, + assetAlbum, + album, + albumAsset, + albumUser, + activity, + memory, + partner, + person, + sharedLink, + stack, + tag, + user, + ]; + + static SyncType? fromJson(dynamic value) => SyncTypeTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SyncType.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [SyncType] to String, +/// and [decode] dynamic data back to [SyncType]. +class SyncTypeTypeTransformer { + factory SyncTypeTypeTransformer() => _instance ??= const SyncTypeTypeTransformer._(); + + const SyncTypeTypeTransformer._(); + + String encode(SyncType data) => data.value; + + /// Decodes a [dynamic value][data] to a SyncType. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + SyncType? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'asset': return SyncType.asset; + case r'asset.partner': return SyncType.assetPeriodPartner; + case r'assetAlbum': return SyncType.assetAlbum; + case r'album': return SyncType.album; + case r'albumAsset': return SyncType.albumAsset; + case r'albumUser': return SyncType.albumUser; + case r'activity': return SyncType.activity; + case r'memory': return SyncType.memory; + case r'partner': return SyncType.partner; + case r'person': return SyncType.person; + case r'sharedLink': return SyncType.sharedLink; + case r'stack': return SyncType.stack; + case r'tag': return SyncType.tag; + case r'user': return SyncType.user; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [SyncTypeTypeTransformer] instance. + static SyncTypeTypeTransformer? _instance; +} + diff --git a/mobile/test/modules/shared/sync_service_test.dart b/mobile/test/modules/shared/sync_service_test.dart index c85487c7d04da..0dfe5e46ae1d8 100644 --- a/mobile/test/modules/shared/sync_service_test.dart +++ b/mobile/test/modules/shared/sync_service_test.dart @@ -48,6 +48,8 @@ void main() { final MockAssetRepository assetRepository = MockAssetRepository(); final MockExifInfoRepository exifInfoRepository = MockExifInfoRepository(); final MockUserRepository userRepository = MockUserRepository(); + final MockSyncRepository syncRepository = MockSyncRepository(); + final MockSyncApiRepository syncApiRepository = MockSyncApiRepository(); final MockETagRepository eTagRepository = MockETagRepository(); final MockAlbumMediaRepository albumMediaRepository = MockAlbumMediaRepository(); @@ -86,6 +88,8 @@ void main() { exifInfoRepository, userRepository, eTagRepository, + syncRepository, + syncApiRepository, ); when(() => eTagRepository.get(owner.isarId)) .thenAnswer((_) async => ETag(id: owner.id, time: DateTime.now())); diff --git a/mobile/test/repository.mocks.dart b/mobile/test/repository.mocks.dart index c76a003eec2a0..6066351050035 100644 --- a/mobile/test/repository.mocks.dart +++ b/mobile/test/repository.mocks.dart @@ -7,6 +7,8 @@ import 'package:immich_mobile/interfaces/backup.interface.dart'; import 'package:immich_mobile/interfaces/etag.interface.dart'; import 'package:immich_mobile/interfaces/exif_info.interface.dart'; import 'package:immich_mobile/interfaces/file_media.interface.dart'; +import 'package:immich_mobile/interfaces/sync.interface.dart'; +import 'package:immich_mobile/interfaces/sync_api.interface.dart'; import 'package:immich_mobile/interfaces/user.interface.dart'; import 'package:mocktail/mocktail.dart'; @@ -29,3 +31,7 @@ class MockAssetMediaRepository extends Mock implements IAssetMediaRepository {} class MockFileMediaRepository extends Mock implements IFileMediaRepository {} class MockAlbumApiRepository extends Mock implements IAlbumApiRepository {} + +class MockSyncRepository extends Mock implements ISyncRepository {} + +class MockSyncApiRepository extends Mock implements ISyncApiRepository {} diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index b99da367b8665..13dacc7c70943 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -5778,6 +5778,41 @@ ] } }, + "/sync/acknowledge": { + "post": { + "operationId": "ackSync", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SyncAcknowledgeDto" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Sync" + ] + } + }, "/sync/delta-sync": { "post": { "operationId": "getDeltaSync", @@ -5865,6 +5900,51 @@ ] } }, + "/sync/stream": { + "post": { + "operationId": "getSyncStream", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SyncStreamDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/SyncStreamResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Sync" + ] + } + }, "/system-config": { "get": { "operationId": "getConfig", @@ -7581,6 +7661,23 @@ ], "type": "object" }, + "AlbumAssetResponseDto": { + "properties": { + "albumId": { + "format": "uuid", + "type": "string" + }, + "assetId": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "albumId", + "assetId" + ], + "type": "object" + }, "AlbumResponseDto": { "properties": { "albumName": { @@ -11456,6 +11553,175 @@ }, "type": "object" }, + "SyncAcknowledgeDto": { + "properties": { + "activity": { + "$ref": "#/components/schemas/SyncCheckpointDto" + }, + "album": { + "$ref": "#/components/schemas/SyncCheckpointDto" + }, + "albumAsset": { + "$ref": "#/components/schemas/SyncCheckpointDto" + }, + "albumUser": { + "$ref": "#/components/schemas/SyncCheckpointDto" + }, + "asset": { + "$ref": "#/components/schemas/SyncCheckpointDto" + }, + "assetAlbum": { + "$ref": "#/components/schemas/SyncCheckpointDto" + }, + "assetPartner": { + "$ref": "#/components/schemas/SyncCheckpointDto" + }, + "memory": { + "$ref": "#/components/schemas/SyncCheckpointDto" + }, + "partner": { + "$ref": "#/components/schemas/SyncCheckpointDto" + }, + "person": { + "$ref": "#/components/schemas/SyncCheckpointDto" + }, + "sharedLink": { + "$ref": "#/components/schemas/SyncCheckpointDto" + }, + "stack": { + "$ref": "#/components/schemas/SyncCheckpointDto" + }, + "tag": { + "$ref": "#/components/schemas/SyncCheckpointDto" + }, + "user": { + "$ref": "#/components/schemas/SyncCheckpointDto" + } + }, + "type": "object" + }, + "SyncAction": { + "enum": [ + "upsert", + "delete" + ], + "type": "string" + }, + "SyncCheckpointDto": { + "properties": { + "id": { + "format": "uuid", + "type": "string" + }, + "timestamp": { + "type": "string" + } + }, + "required": [ + "id", + "timestamp" + ], + "type": "object" + }, + "SyncStreamDto": { + "properties": { + "types": { + "items": { + "enum": [ + "asset", + "asset.partner", + "assetAlbum", + "album", + "albumAsset", + "albumUser", + "activity", + "memory", + "partner", + "person", + "sharedLink", + "stack", + "tag", + "user" + ], + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "types" + ], + "type": "object" + }, + "SyncStreamResponseDto": { + "properties": { + "action": { + "$ref": "#/components/schemas/SyncAction" + }, + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/AssetResponseDto" + }, + { + "$ref": "#/components/schemas/AlbumResponseDto" + }, + { + "$ref": "#/components/schemas/AlbumAssetResponseDto" + }, + { + "$ref": "#/components/schemas/ActivityResponseDto" + }, + { + "$ref": "#/components/schemas/MemoryResponseDto" + }, + { + "$ref": "#/components/schemas/PartnerResponseDto" + }, + { + "$ref": "#/components/schemas/PersonResponseDto" + }, + { + "$ref": "#/components/schemas/SharedLinkResponseDto" + }, + { + "$ref": "#/components/schemas/StackResponseDto" + }, + { + "$ref": "#/components/schemas/UserResponseDto" + } + ] + }, + "type": { + "$ref": "#/components/schemas/SyncType" + } + }, + "required": [ + "action", + "data", + "type" + ], + "type": "object" + }, + "SyncType": { + "enum": [ + "asset", + "asset.partner", + "assetAlbum", + "album", + "albumAsset", + "albumUser", + "activity", + "memory", + "partner", + "person", + "sharedLink", + "stack", + "tag", + "user" + ], + "type": "string" + }, "SystemConfigDto": { "properties": { "ffmpeg": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 17079c07c3445..37630a42742b7 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1069,6 +1069,26 @@ export type StackCreateDto = { export type StackUpdateDto = { primaryAssetId?: string; }; +export type SyncCheckpointDto = { + id: string; + timestamp: string; +}; +export type SyncAcknowledgeDto = { + activity?: SyncCheckpointDto; + album?: SyncCheckpointDto; + albumAsset?: SyncCheckpointDto; + albumUser?: SyncCheckpointDto; + asset?: SyncCheckpointDto; + assetAlbum?: SyncCheckpointDto; + assetPartner?: SyncCheckpointDto; + memory?: SyncCheckpointDto; + partner?: SyncCheckpointDto; + person?: SyncCheckpointDto; + sharedLink?: SyncCheckpointDto; + stack?: SyncCheckpointDto; + tag?: SyncCheckpointDto; + user?: SyncCheckpointDto; +}; export type AssetDeltaSyncDto = { updatedAfter: string; userIds: string[]; @@ -1084,6 +1104,18 @@ export type AssetFullSyncDto = { updatedUntil: string; userId?: string; }; +export type SyncStreamDto = { + types: ("asset" | "asset.partner" | "assetAlbum" | "album" | "albumAsset" | "albumUser" | "activity" | "memory" | "partner" | "person" | "sharedLink" | "stack" | "tag" | "user")[]; +}; +export type AlbumAssetResponseDto = { + albumId: string; + assetId: string; +}; +export type SyncStreamResponseDto = { + action: SyncAction; + data: AssetResponseDto | AlbumResponseDto | AlbumAssetResponseDto | ActivityResponseDto | MemoryResponseDto | PartnerResponseDto | PersonResponseDto | SharedLinkResponseDto | StackResponseDto | UserResponseDto; + "type": SyncType; +}; export type SystemConfigFFmpegDto = { accel: TranscodeHWAccel; accelDecode: boolean; @@ -2852,6 +2884,15 @@ export function updateStack({ id, stackUpdateDto }: { body: stackUpdateDto }))); } +export function ackSync({ syncAcknowledgeDto }: { + syncAcknowledgeDto: SyncAcknowledgeDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/sync/acknowledge", oazapfts.json({ + ...opts, + method: "POST", + body: syncAcknowledgeDto + }))); +} export function getDeltaSync({ assetDeltaSyncDto }: { assetDeltaSyncDto: AssetDeltaSyncDto; }, opts?: Oazapfts.RequestOpts) { @@ -2876,6 +2917,18 @@ export function getFullSyncForUser({ assetFullSyncDto }: { body: assetFullSyncDto }))); } +export function getSyncStream({ syncStreamDto }: { + syncStreamDto: SyncStreamDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: SyncStreamResponseDto[]; + }>("/sync/stream", oazapfts.json({ + ...opts, + method: "POST", + body: syncStreamDto + }))); +} export function getConfig(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -3491,6 +3544,26 @@ export enum Error2 { NoPermission = "no_permission", NotFound = "not_found" } +export enum SyncAction { + Upsert = "upsert", + Delete = "delete" +} +export enum SyncType { + Asset = "asset", + AssetPartner = "asset.partner", + AssetAlbum = "assetAlbum", + Album = "album", + AlbumAsset = "albumAsset", + AlbumUser = "albumUser", + Activity = "activity", + Memory = "memory", + Partner = "partner", + Person = "person", + SharedLink = "sharedLink", + Stack = "stack", + Tag = "tag", + User = "user" +} export enum TranscodeHWAccel { Nvenc = "nvenc", Qsv = "qsv", diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 3c26faaca325c..568cc84dbfab4 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -26,7 +26,7 @@ import { teardownTelemetry } from 'src/repositories/telemetry.repository'; import { services } from 'src/services'; import { DatabaseService } from 'src/services/database.service'; -const common = [...services, ...repositories]; +const common = [...services, ...repositories, GlobalExceptionFilter]; const middleware = [ FileUploadInterceptor, diff --git a/server/src/controllers/sync.controller.ts b/server/src/controllers/sync.controller.ts index 4d970a7102a8f..0b02fae345f1f 100644 --- a/server/src/controllers/sync.controller.ts +++ b/server/src/controllers/sync.controller.ts @@ -1,27 +1,60 @@ -import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; +import { Body, Controller, Header, HttpCode, HttpStatus, Post, Res } from '@nestjs/common'; +import { ApiResponse, ApiTags } from '@nestjs/swagger'; +import { Response } from 'express'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { AssetDeltaSyncDto, AssetDeltaSyncResponseDto, AssetFullSyncDto } from 'src/dtos/sync.dto'; +import { + AssetDeltaSyncDto, + AssetDeltaSyncResponseDto, + AssetFullSyncDto, + SyncAcknowledgeDto, + SyncStreamDto, + SyncStreamResponseDto, +} from 'src/dtos/sync.dto'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; +import { GlobalExceptionFilter } from 'src/middleware/global-exception.filter'; import { SyncService } from 'src/services/sync.service'; @ApiTags('Sync') @Controller('sync') export class SyncController { - constructor(private service: SyncService) {} + constructor( + private syncService: SyncService, + private errorService: GlobalExceptionFilter, + ) {} + + @Post('acknowledge') + @HttpCode(HttpStatus.NO_CONTENT) + @Authenticated() + ackSync(@Auth() auth: AuthDto, @Body() dto: SyncAcknowledgeDto) { + return this.syncService.acknowledge(auth, dto); + } + + @Post('stream') + @Header('Content-Type', 'application/jsonlines+json') + @HttpCode(HttpStatus.OK) + @ApiResponse({ status: 200, type: SyncStreamResponseDto, isArray: true }) + @Authenticated() + getSyncStream(@Auth() auth: AuthDto, @Res() res: Response, @Body() dto: SyncStreamDto) { + try { + void this.syncService.stream(auth, res, dto); + } catch (error: Error | any) { + res.setHeader('Content-Type', 'application/json'); + this.errorService.handleError(res, error); + } + } @Post('full-sync') @HttpCode(HttpStatus.OK) @Authenticated() getFullSyncForUser(@Auth() auth: AuthDto, @Body() dto: AssetFullSyncDto): Promise { - return this.service.getFullSync(auth, dto); + return this.syncService.getFullSync(auth, dto); } @Post('delta-sync') @HttpCode(HttpStatus.OK) @Authenticated() getDeltaSync(@Auth() auth: AuthDto, @Body() dto: AssetDeltaSyncDto): Promise { - return this.service.getDeltaSync(auth, dto); + return this.syncService.getDeltaSync(auth, dto); } } diff --git a/server/src/dtos/album-asset.dto.ts b/server/src/dtos/album-asset.dto.ts new file mode 100644 index 0000000000000..9a62a4e0095ac --- /dev/null +++ b/server/src/dtos/album-asset.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class AlbumAssetResponseDto { + @ApiProperty({ format: 'uuid' }) + albumId!: string; + + @ApiProperty({ format: 'uuid' }) + assetId!: string; +} diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index 820de8d6c3320..e9d4e6f35af6c 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -1,7 +1,140 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsInt, IsPositive } from 'class-validator'; +import { ApiProperty, getSchemaPath } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { ArrayNotEmpty, IsDateString, IsEnum, IsInt, IsPositive, ValidateNested } from 'class-validator'; +import { ActivityResponseDto } from 'src/dtos/activity.dto'; +import { AlbumAssetResponseDto } from 'src/dtos/album-asset.dto'; +import { AlbumResponseDto } from 'src/dtos/album.dto'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; -import { ValidateDate, ValidateUUID } from 'src/validation'; +import { MemoryResponseDto } from 'src/dtos/memory.dto'; +import { PartnerResponseDto } from 'src/dtos/partner.dto'; +import { PersonResponseDto } from 'src/dtos/person.dto'; +import { SharedLinkResponseDto } from 'src/dtos/shared-link.dto'; +import { StackResponseDto } from 'src/dtos/stack.dto'; +import { UserResponseDto } from 'src/dtos/user.dto'; +import { SyncState } from 'src/entities/session-sync-state.entity'; +import { SyncAction, SyncEntity } from 'src/enum'; +import { Optional, ValidateDate, ValidateUUID } from 'src/validation'; + +class SyncCheckpointDto { + @ValidateUUID() + id!: string; + + @IsDateString() + timestamp!: string; +} + +export class SyncAcknowledgeDto implements SyncState { + @Optional() + @ValidateNested() + @Type(() => SyncCheckpointDto) + activity?: SyncCheckpointDto; + + @Optional() + @ValidateNested() + @Type(() => SyncCheckpointDto) + album?: SyncCheckpointDto; + + @Optional() + @ValidateNested() + @Type(() => SyncCheckpointDto) + albumUser?: SyncCheckpointDto; + + @Optional() + @ValidateNested() + @Type(() => SyncCheckpointDto) + albumAsset?: SyncCheckpointDto; + + @Optional() + @ValidateNested() + @Type(() => SyncCheckpointDto) + asset?: SyncCheckpointDto; + + @Optional() + @ValidateNested() + @Type(() => SyncCheckpointDto) + assetAlbum?: SyncCheckpointDto; + + @Optional() + @ValidateNested() + @Type(() => SyncCheckpointDto) + assetPartner?: SyncCheckpointDto; + + @Optional() + @ValidateNested() + @Type(() => SyncCheckpointDto) + memory?: SyncCheckpointDto; + + @Optional() + @ValidateNested() + @Type(() => SyncCheckpointDto) + partner?: SyncCheckpointDto; + + @Optional() + @ValidateNested() + @Type(() => SyncCheckpointDto) + person?: SyncCheckpointDto; + + @Optional() + @ValidateNested() + @Type(() => SyncCheckpointDto) + sharedLink?: SyncCheckpointDto; + + @Optional() + @ValidateNested() + @Type(() => SyncCheckpointDto) + stack?: SyncCheckpointDto; + + @Optional() + @ValidateNested() + @Type(() => SyncCheckpointDto) + tag?: SyncCheckpointDto; + + @Optional() + @ValidateNested() + @Type(() => SyncCheckpointDto) + user?: SyncCheckpointDto; +} + +export class SyncStreamResponseDto { + @ApiProperty({ enum: SyncEntity, enumName: 'SyncType' }) + type!: SyncEntity; + + @ApiProperty({ enum: SyncAction, enumName: 'SyncAction' }) + action!: SyncAction; + + @ApiProperty({ + anyOf: [ + { $ref: getSchemaPath(AssetResponseDto) }, + { $ref: getSchemaPath(AlbumResponseDto) }, + { $ref: getSchemaPath(AlbumAssetResponseDto) }, + { $ref: getSchemaPath(ActivityResponseDto) }, + { $ref: getSchemaPath(MemoryResponseDto) }, + { $ref: getSchemaPath(PartnerResponseDto) }, + { $ref: getSchemaPath(PersonResponseDto) }, + { $ref: getSchemaPath(SharedLinkResponseDto) }, + { $ref: getSchemaPath(StackResponseDto) }, + { $ref: getSchemaPath(UserResponseDto) }, + ], + }) + data!: + | ActivityResponseDto + | AssetResponseDto + | AlbumResponseDto + | AlbumAssetResponseDto + | MemoryResponseDto + | PartnerResponseDto + | PersonResponseDto + | SharedLinkResponseDto + | StackResponseDto + | UserResponseDto; +} + +export class SyncStreamDto { + @IsEnum(SyncEntity, { each: true }) + @ApiProperty({ enum: SyncEntity, isArray: true }) + @ArrayNotEmpty() + types!: SyncEntity[]; +} export class AssetFullSyncDto { @ValidateUUID({ optional: true }) diff --git a/server/src/entities/index.ts b/server/src/entities/index.ts index 7425ee67d8a6e..8495288fb403b 100644 --- a/server/src/entities/index.ts +++ b/server/src/entities/index.ts @@ -16,6 +16,7 @@ import { MoveEntity } from 'src/entities/move.entity'; import { NaturalEarthCountriesEntity } from 'src/entities/natural-earth-countries.entity'; import { PartnerEntity } from 'src/entities/partner.entity'; import { PersonEntity } from 'src/entities/person.entity'; +import { SessionSyncStateEntity } from 'src/entities/session-sync-state.entity'; import { SessionEntity } from 'src/entities/session.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { SmartInfoEntity } from 'src/entities/smart-info.entity'; @@ -54,6 +55,7 @@ export const entities = [ UserEntity, UserMetadataEntity, SessionEntity, + SessionSyncStateEntity, LibraryEntity, VersionHistoryEntity, ]; diff --git a/server/src/entities/session-sync-state.entity.ts b/server/src/entities/session-sync-state.entity.ts new file mode 100644 index 0000000000000..e6372cffbffb7 --- /dev/null +++ b/server/src/entities/session-sync-state.entity.ts @@ -0,0 +1,52 @@ +import { SessionEntity } from 'src/entities/session.entity'; +import { Column, CreateDateColumn, Entity, JoinColumn, OneToOne, PrimaryColumn, UpdateDateColumn } from 'typeorm'; + +export type SyncCheckpoint = { + id: string; + timestamp: string; +}; + +export type SyncState = { + activity?: SyncCheckpoint; + + album?: SyncCheckpoint; + albumUser?: SyncCheckpoint; + albumAsset?: SyncCheckpoint; + + asset?: SyncCheckpoint; + assetAlbum?: SyncCheckpoint; + assetPartner?: SyncCheckpoint; + + memory?: SyncCheckpoint; + + partner?: SyncCheckpoint; + + person?: SyncCheckpoint; + + sharedLink?: SyncCheckpoint; + + stack?: SyncCheckpoint; + + tag?: SyncCheckpoint; + + user?: SyncCheckpoint; +}; + +@Entity('session_sync_states') +export class SessionSyncStateEntity { + @OneToOne(() => SessionEntity, { onDelete: 'CASCADE', nullable: true }) + @JoinColumn() + session?: SessionEntity; + + @PrimaryColumn() + sessionId!: string; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt!: Date; + + @Column({ type: 'jsonb', nullable: true }) + state?: SyncState; +} diff --git a/server/src/enum.ts b/server/src/enum.ts index 902d6635e7f52..d8abec892977d 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -53,6 +53,28 @@ export enum DatabaseAction { DELETE = 'DELETE', } +export enum SyncEntity { + ASSET = 'asset', + ASSET_PARTNER = 'asset.partner', + ASSET_ALBUM = 'assetAlbum', + ALBUM = 'album', + ALBUM_ASSET = 'albumAsset', + ALBUM_USER = 'albumUser', + ACTIVITY = 'activity', + MEMORY = 'memory', + PARTNER = 'partner', + PERSON = 'person', + SHARED_LINK = 'sharedLink', + STACK = 'stack', + TAG = 'tag', + USER = 'user', +} + +export enum SyncAction { + UPSERT = 'upsert', + DELETE = 'delete', +} + export enum EntityType { ASSET = 'ASSET', ALBUM = 'ALBUM', diff --git a/server/src/interfaces/sync.interface.ts b/server/src/interfaces/sync.interface.ts new file mode 100644 index 0000000000000..4ed5be5ca6383 --- /dev/null +++ b/server/src/interfaces/sync.interface.ts @@ -0,0 +1,43 @@ +import { AlbumEntity } from 'src/entities/album.entity'; +import { AssetEntity } from 'src/entities/asset.entity'; +import { SessionSyncStateEntity, SyncCheckpoint } from 'src/entities/session-sync-state.entity'; +import { Paginated, PaginationOptions } from 'src/utils/pagination'; + +export const ISyncRepository = 'ISyncRepository'; + +export type SyncOptions = PaginationOptions & { + userId: string; + checkpoint?: SyncCheckpoint; +}; + +export type AssetPartnerSyncOptions = SyncOptions & { partnerIds: string[] }; + +export type EntityPK = { id: string }; +export type DeletedEntity = T & { + deletedAt: Date; +}; +export type AlbumAssetPK = { + albumId: string; + assetId: string; +}; + +export type AlbumAssetEntity = AlbumAssetPK & { + createdAt: Date; +}; + +export interface ISyncRepository { + get(sessionId: string): Promise; + upsert(state: Partial): Promise; + + getAssets(options: SyncOptions): Paginated; + getDeletedAssets(options: SyncOptions): Paginated; + + getAssetsPartner(options: AssetPartnerSyncOptions): Paginated; + getDeletedAssetsPartner(options: AssetPartnerSyncOptions): Paginated; + + getAlbums(options: SyncOptions): Paginated; + getDeletedAlbums(options: SyncOptions): Paginated; + + getAlbumAssets(options: SyncOptions): Paginated; + getDeletedAlbumAssets(options: SyncOptions): Paginated>; +} diff --git a/server/src/middleware/global-exception.filter.ts b/server/src/middleware/global-exception.filter.ts index 6200363e86e9e..9aeeed5c57890 100644 --- a/server/src/middleware/global-exception.filter.ts +++ b/server/src/middleware/global-exception.filter.ts @@ -1,9 +1,10 @@ -import { ArgumentsHost, Catch, ExceptionFilter, HttpException, Inject } from '@nestjs/common'; +import { ArgumentsHost, Catch, ExceptionFilter, HttpException, Inject, Injectable } from '@nestjs/common'; import { Response } from 'express'; import { ClsService } from 'nestjs-cls'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { logGlobalError } from 'src/utils/logger'; +@Injectable() @Catch() export class GlobalExceptionFilter implements ExceptionFilter { constructor( @@ -15,10 +16,13 @@ export class GlobalExceptionFilter implements ExceptionFilter { catch(error: Error, host: ArgumentsHost) { const ctx = host.switchToHttp(); - const response = ctx.getResponse(); + this.handleError(ctx.getResponse(), error); + } + + handleError(res: Response, error: Error) { const { status, body } = this.fromError(error); - if (!response.headersSent) { - response.status(status).json({ ...body, statusCode: status, correlationId: this.cls.getId() }); + if (!res.headersSent) { + res.status(status).json({ ...body, statusCode: status, correlationId: this.cls.getId() }); } } diff --git a/server/src/migrations/1729792220961-AddSessionStateTable.ts b/server/src/migrations/1729792220961-AddSessionStateTable.ts new file mode 100644 index 0000000000000..932c4e7e685f1 --- /dev/null +++ b/server/src/migrations/1729792220961-AddSessionStateTable.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddSessionStateTable1729792220961 implements MigrationInterface { + name = 'AddSessionStateTable1729792220961' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "session_sync_states" ("sessionId" uuid NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "state" jsonb, CONSTRAINT "PK_4821e7414daba4413b8b33546d1" PRIMARY KEY ("sessionId"))`); + await queryRunner.query(`ALTER TABLE "session_sync_states" ADD CONSTRAINT "FK_4821e7414daba4413b8b33546d1" FOREIGN KEY ("sessionId") REFERENCES "sessions"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "session_sync_states" DROP CONSTRAINT "FK_4821e7414daba4413b8b33546d1"`); + await queryRunner.query(`DROP TABLE "session_sync_states"`); + } + +} diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index 94a0212204740..bed80aaf34721 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -28,6 +28,7 @@ import { ISessionRepository } from 'src/interfaces/session.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; import { IStackRepository } from 'src/interfaces/stack.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; +import { ISyncRepository } from 'src/interfaces/sync.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ITagRepository } from 'src/interfaces/tag.interface'; import { ITelemetryRepository } from 'src/interfaces/telemetry.interface'; @@ -65,6 +66,7 @@ import { SessionRepository } from 'src/repositories/session.repository'; import { SharedLinkRepository } from 'src/repositories/shared-link.repository'; import { StackRepository } from 'src/repositories/stack.repository'; import { StorageRepository } from 'src/repositories/storage.repository'; +import { SyncRepository } from 'src/repositories/sync.repository'; import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; import { TagRepository } from 'src/repositories/tag.repository'; import { TelemetryRepository } from 'src/repositories/telemetry.repository'; @@ -104,6 +106,7 @@ export const repositories = [ { provide: ISharedLinkRepository, useClass: SharedLinkRepository }, { provide: IStackRepository, useClass: StackRepository }, { provide: IStorageRepository, useClass: StorageRepository }, + { provide: ISyncRepository, useClass: SyncRepository }, { provide: ISystemMetadataRepository, useClass: SystemMetadataRepository }, { provide: ITagRepository, useClass: TagRepository }, { provide: ITelemetryRepository, useClass: TelemetryRepository }, diff --git a/server/src/repositories/sync.repository.ts b/server/src/repositories/sync.repository.ts new file mode 100644 index 0000000000000..ffc50bc18ff50 --- /dev/null +++ b/server/src/repositories/sync.repository.ts @@ -0,0 +1,114 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { AlbumEntity } from 'src/entities/album.entity'; +import { AssetEntity } from 'src/entities/asset.entity'; +import { SessionSyncStateEntity, SyncCheckpoint } from 'src/entities/session-sync-state.entity'; +import { + AlbumAssetEntity, + AlbumAssetPK, + AssetPartnerSyncOptions, + DeletedEntity, + EntityPK, + ISyncRepository, + SyncOptions, +} from 'src/interfaces/sync.interface'; +import { paginate, Paginated } from 'src/utils/pagination'; +import { DataSource, FindOptionsWhere, In, MoreThan, MoreThanOrEqual, Repository } from 'typeorm'; + +const withCheckpoint = (where: FindOptionsWhere, key: keyof T, checkpoint?: SyncCheckpoint) => { + if (!checkpoint) { + return [where]; + } + + const { id: checkpointId, timestamp } = checkpoint; + const checkpointDate = new Date(timestamp); + return [ + { + ...where, + [key]: MoreThanOrEqual(new Date(checkpointDate)), + id: MoreThan(checkpointId), + }, + { + ...where, + [key]: MoreThan(checkpointDate), + }, + ]; +}; + +@Injectable() +export class SyncRepository implements ISyncRepository { + constructor( + private dataSource: DataSource, + @InjectRepository(AssetEntity) private assetRepository: Repository, + @InjectRepository(AlbumEntity) private albumRepository: Repository, + @InjectRepository(SessionSyncStateEntity) private repository: Repository, + ) {} + + get(sessionId: string): Promise { + return this.repository.findOneBy({ sessionId }); + } + + async upsert(state: Partial): Promise { + await this.repository.upsert(state, { conflictPaths: ['sessionId'] }); + } + + getAssets({ checkpoint, userId, ...options }: AssetPartnerSyncOptions): Paginated { + return paginate(this.assetRepository, options, { + where: withCheckpoint( + { + ownerId: userId, + isVisible: true, + }, + 'updatedAt', + checkpoint, + ), + relations: { + exifInfo: true, + }, + order: { + updatedAt: 'ASC', + id: 'ASC', + }, + }); + } + + getDeletedAssets(): Paginated> { + return Promise.resolve({ items: [], hasNextPage: false }); + } + + getAssetsPartner({ checkpoint, partnerIds, ...options }: AssetPartnerSyncOptions): Paginated { + return paginate(this.assetRepository, options, { + where: withCheckpoint({ ownerId: In(partnerIds) }, 'updatedAt', checkpoint), + order: { + updatedAt: 'ASC', + id: 'ASC', + }, + }); + } + + getDeletedAssetsPartner(): Paginated> { + return Promise.resolve({ items: [], hasNextPage: false }); + } + + getAlbums({ checkpoint, userId, ...options }: SyncOptions): Paginated { + return paginate(this.albumRepository, options, { + where: withCheckpoint({ ownerId: userId }, 'updatedAt', checkpoint), + order: { + updatedAt: 'ASC', + id: 'ASC', + }, + }); + } + + getDeletedAlbums(): Paginated> { + return Promise.resolve({ items: [], hasNextPage: false }); + } + + getAlbumAssets(): Paginated { + return Promise.resolve({ items: [], hasNextPage: false }); + } + + getDeletedAlbumAssets(): Paginated> { + return Promise.resolve({ items: [], hasNextPage: false }); + } +} diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index 441a81cf91031..f9de3296c4192 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -31,6 +31,7 @@ import { ISessionRepository } from 'src/interfaces/session.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; import { IStackRepository } from 'src/interfaces/stack.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; +import { ISyncRepository } from 'src/interfaces/sync.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ITagRepository } from 'src/interfaces/tag.interface'; import { ITelemetryRepository } from 'src/interfaces/telemetry.interface'; @@ -75,6 +76,7 @@ export class BaseService { @Inject(ISharedLinkRepository) protected sharedLinkRepository: ISharedLinkRepository, @Inject(IStackRepository) protected stackRepository: IStackRepository, @Inject(IStorageRepository) protected storageRepository: IStorageRepository, + @Inject(ISyncRepository) protected syncRepository: ISyncRepository, @Inject(ISystemMetadataRepository) protected systemMetadataRepository: ISystemMetadataRepository, @Inject(ITagRepository) protected tagRepository: ITagRepository, @Inject(ITelemetryRepository) protected telemetryRepository: ITelemetryRepository, diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index f85200db489fa..13d9b064d4b37 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -1,16 +1,184 @@ +import { ForbiddenException } from '@nestjs/common'; import { DateTime } from 'luxon'; +import { Writable } from 'node:stream'; import { AUDIT_LOG_MAX_DURATION } from 'src/constants'; +import { AlbumAssetResponseDto } from 'src/dtos/album-asset.dto'; +import { AlbumResponseDto, mapAlbumWithoutAssets } from 'src/dtos/album.dto'; import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { AssetDeltaSyncDto, AssetDeltaSyncResponseDto, AssetFullSyncDto } from 'src/dtos/sync.dto'; -import { DatabaseAction, EntityType, Permission } from 'src/enum'; +import { + AssetDeltaSyncDto, + AssetDeltaSyncResponseDto, + AssetFullSyncDto, + SyncAcknowledgeDto, + SyncStreamDto, +} from 'src/dtos/sync.dto'; +import { AlbumEntity } from 'src/entities/album.entity'; +import { AssetEntity } from 'src/entities/asset.entity'; +import { SyncCheckpoint } from 'src/entities/session-sync-state.entity'; +import { DatabaseAction, EntityType, Permission, SyncAction, SyncEntity as SyncEntityType } from 'src/enum'; +import { AlbumAssetEntity, DeletedEntity, SyncOptions } from 'src/interfaces/sync.interface'; import { BaseService } from 'src/services/base.service'; import { getMyPartnerIds } from 'src/utils/asset.util'; +import { Paginated, usePagination } from 'src/utils/pagination'; import { setIsEqual } from 'src/utils/set'; const FULL_SYNC = { needsFullSync: true, deleted: [], upserted: [] }; +const SYNC_PAGE_SIZE = 5000; + +const asJsonLine = (item: unknown) => JSON.stringify(item) + '\n'; + +type Loader = (options: SyncOptions) => Paginated; +type Mapper = (item: T) => R; +type StreamerArgs = { + type: SyncEntityType; + action: SyncAction; + lastAck?: string; + load: Loader; + map?: Mapper; + ack: Mapper; +}; + +class Streamer { + constructor(private args: StreamerArgs) {} + + getEntityType() { + return this.args.type; + } + + async write({ stream, userId, checkpoint }: { stream: Writable; userId: string; checkpoint?: SyncCheckpoint }) { + const { type, action, load, map, ack } = this.args; + const pagination = usePagination(SYNC_PAGE_SIZE, (options) => load({ ...options, userId, checkpoint })); + for await (const items of pagination) { + for (const item of items) { + stream.write(asJsonLine({ type, action, data: map?.(item) || (item as unknown as R), ack: ack(item) })); + } + } + } +} export class SyncService extends BaseService { + async acknowledge(auth: AuthDto, dto: SyncAcknowledgeDto) { + const { id: sessionId } = this.assertSession(auth); + await this.syncRepository.upsert({ + ...dto, + sessionId, + }); + } + + async stream(auth: AuthDto, stream: Writable, dto: SyncStreamDto) { + const { id: sessionId, userId } = this.assertSession(auth); + const syncState = await this.syncRepository.get(sessionId); + const state = syncState?.state; + const checkpoints: Record = { + [SyncEntityType.ACTIVITY]: state?.activity, + [SyncEntityType.ASSET]: state?.asset, + [SyncEntityType.ASSET_ALBUM]: state?.assetAlbum, + [SyncEntityType.ASSET_PARTNER]: state?.assetPartner, + [SyncEntityType.ALBUM]: state?.album, + [SyncEntityType.ALBUM_ASSET]: state?.albumAsset, + [SyncEntityType.ALBUM_USER]: state?.albumUser, + [SyncEntityType.MEMORY]: state?.memory, + [SyncEntityType.PARTNER]: state?.partner, + [SyncEntityType.PERSON]: state?.partner, + [SyncEntityType.SHARED_LINK]: state?.sharedLink, + [SyncEntityType.STACK]: state?.stack, + [SyncEntityType.TAG]: state?.tag, + [SyncEntityType.USER]: state?.user, + }; + const streamers: Streamer[] = []; + + for (const type of dto.types) { + switch (type) { + case SyncEntityType.ASSET: { + streamers.push( + new Streamer({ + type: SyncEntityType.ASSET, + action: SyncAction.UPSERT, + load: (options) => this.syncRepository.getAssets(options), + map: (item) => mapAsset(item, { auth, stripMetadata: false }), + ack: (item) => ({ id: item.id, timestamp: item.updatedAt.toISOString() }), + }), + new Streamer({ + type: SyncEntityType.ASSET, + action: SyncAction.DELETE, + load: (options) => this.syncRepository.getDeletedAssets(options), + map: (entity) => entity, + ack: (item) => ({ id: item.id, timestamp: item.deletedAt.toISOString() }), + }), + ); + break; + } + + case SyncEntityType.ASSET_PARTNER: { + const partnerIds = await getMyPartnerIds({ userId, repository: this.partnerRepository }); + streamers.push( + new Streamer({ + type: SyncEntityType.ASSET_PARTNER, + action: SyncAction.UPSERT, + load: (options) => this.syncRepository.getAssetsPartner({ ...options, partnerIds }), + map: (item) => mapAsset(item, { auth, stripMetadata: false }), + ack: (item) => ({ id: item.id, timestamp: item.updatedAt.toISOString() }), + }), + new Streamer({ + type: SyncEntityType.ASSET_PARTNER, + action: SyncAction.DELETE, + load: (options) => this.syncRepository.getDeletedAssetsPartner({ ...options, partnerIds }), + ack: (item) => ({ id: item.id, timestamp: item.deletedAt.toISOString() }), + }), + ); + break; + } + + case SyncEntityType.ALBUM: { + streamers.push( + new Streamer({ + type: SyncEntityType.ALBUM, + action: SyncAction.UPSERT, + load: (options) => this.syncRepository.getAlbums(options), + map: (item) => mapAlbumWithoutAssets(item), + ack: (item) => ({ id: item.id, timestamp: item.updatedAt.toISOString() }), + }), + new Streamer({ + type: SyncEntityType.ALBUM, + action: SyncAction.DELETE, + load: (options) => this.syncRepository.getDeletedAlbums(options), + ack: (item) => ({ id: item.id, timestamp: item.deletedAt.toISOString() }), + }), + ); + } + + case SyncEntityType.ALBUM_ASSET: { + streamers.push( + new Streamer({ + type: SyncEntityType.ALBUM_ASSET, + action: SyncAction.UPSERT, + load: (options) => this.syncRepository.getAlbumAssets(options), + ack: (item) => ({ id: item.assetId, timestamp: item.createdAt.toISOString() }), + }), + new Streamer({ + type: SyncEntityType.ALBUM_ASSET, + action: SyncAction.DELETE, + load: (options) => this.syncRepository.getDeletedAlbums(options), + ack: (item) => ({ id: item.id, timestamp: item.deletedAt.toISOString() }), + }), + ); + } + + default: { + this.logger.warn(`Unsupported sync type: ${type}`); + break; + } + } + } + + for (const streamer of streamers) { + await streamer.write({ stream, userId, checkpoint: checkpoints[streamer.getEntityType()] }); + } + + stream.end(); + } + async getFullSync(auth: AuthDto, dto: AssetFullSyncDto): Promise { // mobile implementation is faster if this is a single id const userId = dto.userId || auth.user.id; @@ -71,4 +239,12 @@ export class SyncService extends BaseService { }; return result; } + + private assertSession(auth: AuthDto) { + if (!auth.session?.id) { + throw new ForbiddenException('This endpoint requires session-based authentication'); + } + + return auth.session; + } } diff --git a/server/src/utils/misc.ts b/server/src/utils/misc.ts index 6e435e68a8dc7..4806ab14f71b4 100644 --- a/server/src/utils/misc.ts +++ b/server/src/utils/misc.ts @@ -12,6 +12,7 @@ import { writeFileSync } from 'node:fs'; import path from 'node:path'; import { SystemConfig } from 'src/config'; import { CLIP_MODEL_INFO, serverVersion } from 'src/constants'; +import { AlbumAssetResponseDto } from 'src/dtos/album-asset.dto'; import { ImmichCookie, ImmichHeader, MetadataKey } from 'src/enum'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; @@ -219,6 +220,7 @@ export const useSwagger = (app: INestApplication, { write }: { write: boolean }) const options: SwaggerDocumentOptions = { operationIdFactory: (controllerKey: string, methodKey: string) => methodKey, + extraModels: [AlbumAssetResponseDto], }; const specification = SwaggerModule.createDocument(app, config, options); diff --git a/server/test/repositories/sync.repository.mock.ts b/server/test/repositories/sync.repository.mock.ts new file mode 100644 index 0000000000000..b4f5bdaefe04f --- /dev/null +++ b/server/test/repositories/sync.repository.mock.ts @@ -0,0 +1,13 @@ +import { ISyncRepository } from 'src/interfaces/sync.interface'; +import { Mocked, vitest } from 'vitest'; + +export const newSyncRepositoryMock = (): Mocked => { + return { + get: vitest.fn(), + upsert: vitest.fn(), + + getAssets: vitest.fn(), + getAlbums: vitest.fn(), + getAlbumAssets: vitest.fn(), + }; +}; diff --git a/server/test/utils.ts b/server/test/utils.ts index 9a40a22c2c01d..c86f3e56130a6 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -31,6 +31,7 @@ import { newSessionRepositoryMock } from 'test/repositories/session.repository.m import { newSharedLinkRepositoryMock } from 'test/repositories/shared-link.repository.mock'; import { newStackRepositoryMock } from 'test/repositories/stack.repository.mock'; import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; +import { newSyncRepositoryMock } from 'test/repositories/sync.repository.mock'; import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; import { newTagRepositoryMock } from 'test/repositories/tag.repository.mock'; import { newTelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock'; @@ -84,6 +85,7 @@ export const newTestService = ( const sharedLinkMock = newSharedLinkRepositoryMock(); const stackMock = newStackRepositoryMock(); const storageMock = newStorageRepositoryMock(); + const syncMock = newSyncRepositoryMock(); const systemMock = newSystemMetadataRepositoryMock(); const tagMock = newTagRepositoryMock(); const telemetryMock = newTelemetryRepositoryMock(); @@ -123,6 +125,7 @@ export const newTestService = ( sharedLinkMock, stackMock, storageMock, + syncMock, systemMock, tagMock, telemetryMock, @@ -164,6 +167,7 @@ export const newTestService = ( sharedLinkMock, stackMock, storageMock, + syncMock, systemMock, tagMock, telemetryMock, diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 8f8bd033eb412..1de253ba4f190 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -29,6 +29,34 @@ $: if ($user) { openWebsocketConnection(); + + void fetch('/api/sync/stream', { + method: 'POST', + body: JSON.stringify({ types: ['asset'] }), + headers: { 'Content-Type': 'application/json' }, + }).then(async (response) => { + if (response.body) { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + + let done = false; + while (!done) { + const chunk = await reader.read(); + done = chunk.done; + const data = chunk.value; + + if (data) { + const parts = decoder.decode(data).split('\n'); + for (const part of parts) { + if (!part.trim()) { + continue; + } + console.log(JSON.parse(part)); + } + } + } + } + }); } else { closeWebsocketConnection(); }