From 90dfffaadf7319c40971e9ad2a38b9746ca15852 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 23 Oct 2024 10:01:08 -0500 Subject: [PATCH 01/15] chore(mobile): boiler plate for new stream sync mechanism --- mobile/lib/interfaces/sync.interface.dart | 16 ++++ mobile/lib/interfaces/sync_api.interface.dart | 4 + mobile/lib/repositories/sync.repository.dart | 81 +++++++++++++++++++ .../lib/repositories/sync_api.repository.dart | 46 +++++++++++ mobile/lib/services/background.service.dart | 5 ++ mobile/lib/services/sync.service.dart | 43 +++++++++- .../modules/shared/sync_service_test.dart | 2 + mobile/test/repository.mocks.dart | 3 + 8 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 mobile/lib/interfaces/sync.interface.dart create mode 100644 mobile/lib/interfaces/sync_api.interface.dart create mode 100644 mobile/lib/repositories/sync.repository.dart create mode 100644 mobile/lib/repositories/sync_api.repository.dart diff --git a/mobile/lib/interfaces/sync.interface.dart b/mobile/lib/interfaces/sync.interface.dart new file mode 100644 index 0000000000000..c17007b3db184 --- /dev/null +++ b/mobile/lib/interfaces/sync.interface.dart @@ -0,0 +1,16 @@ +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/interfaces/database.interface.dart'; + +abstract interface class ISyncRepository implements IDatabaseRepository { + void Function(Asset)? onAssetAdded; + void Function(Asset)? onAssetDeleted; + void Function(Asset)? onAssetUpdated; + + void Function(Album)? onAlbumAdded; + void Function(Album)? onAlbumDeleted; + void Function(Album)? onAlbumUpdated; + + Future fullSync(); + Future incrementalSync(); +} diff --git a/mobile/lib/interfaces/sync_api.interface.dart b/mobile/lib/interfaces/sync_api.interface.dart new file mode 100644 index 0000000000000..0baa14cc5c469 --- /dev/null +++ b/mobile/lib/interfaces/sync_api.interface.dart @@ -0,0 +1,4 @@ +abstract interface class ISyncApiRepository { + Stream getChanges(); + Future confirmChages(String changeId); +} diff --git a/mobile/lib/repositories/sync.repository.dart b/mobile/lib/repositories/sync.repository.dart new file mode 100644 index 0000000000000..3a8da6944fd0c --- /dev/null +++ b/mobile/lib/repositories/sync.repository.dart @@ -0,0 +1,81 @@ +import 'dart:convert'; + +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +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:immich_mobile/repositories/sync_api.repository.dart'; +import 'package:openapi/api.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +final syncRepositoryProvider = Provider( + (ref) => SyncRepository( + ref.watch(dbProvider), + ref.watch(syncApiRepositoryProvider), + ), +); + +class SyncRepository extends DatabaseRepository implements ISyncRepository { + @override + void Function(Album)? onAlbumAdded; + + @override + void Function(Album)? onAlbumDeleted; + + @override + void Function(Album)? onAlbumUpdated; + + @override + void Function(Asset)? onAssetAdded; + + @override + void Function(Asset)? onAssetDeleted; + + @override + void Function(Asset)? onAssetUpdated; + + final SyncApiRepository _apiRepository; + + SyncRepository(super.db, this._apiRepository); + + @override + Future fullSync() { + // TODO: implement fullSync + throw UnimplementedError(); + } + + @override + Future incrementalSync() async { + _apiRepository.getChanges().listen((event) async { + final type = jsonDecode(event)['type']; + final data = jsonDecode(event)['data']; + + if (type == 'album_added') { + final dto = AlbumResponseDto.fromJson(data); + final album = await Album.remote(dto!); + onAlbumAdded?.call(album); + } else if (type == 'album_deleted') { + final dto = AlbumResponseDto.fromJson(data); + final album = await Album.remote(dto!); + onAlbumDeleted?.call(album); + } else if (type == 'album_updated') { + final dto = AlbumResponseDto.fromJson(data); + final album = await Album.remote(dto!); + onAlbumUpdated?.call(album); + } else if (type == 'asset_added') { + final dto = AssetResponseDto.fromJson(data); + final asset = Asset.remote(dto!); + onAssetAdded?.call(asset); + } else if (type == 'asset_deleted') { + final dto = AssetResponseDto.fromJson(data); + final asset = Asset.remote(dto!); + onAssetDeleted?.call(asset); + } else if (type == 'asset_updated') { + final dto = AssetResponseDto.fromJson(data); + final asset = Asset.remote(dto!); + onAssetUpdated?.call(asset); + } + }); + } +} diff --git a/mobile/lib/repositories/sync_api.repository.dart b/mobile/lib/repositories/sync_api.repository.dart new file mode 100644 index 0000000000000..8881593c04fc3 --- /dev/null +++ b/mobile/lib/repositories/sync_api.repository.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/interfaces/sync_api.interface.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() async* { + final url = Uri.parse('http://my-api-server.com/stream'); + final client = http.Client(); + + try { + final request = http.Request('GET', url); + final response = await client.send(request); + + // Read and print the chunks from the response stream + await for (var chunk in response.stream.transform(utf8.decoder)) { + // Process each chunk as it is received + yield chunk; + } + } catch (e) { + debugPrint("Error: $e"); + } finally { + client.close(); + } + } + + @override + Future confirmChages(String changeId) async { + // TODO: implement confirmChages + throw UnimplementedError(); + } +} diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index 3959e2a6edc2b..f08b85eb9c0e5 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, syncApiRepository); AlbumMediaRepository albumMediaRepository = AlbumMediaRepository(); FileMediaRepository fileMediaRepository = FileMediaRepository(); AssetMediaRepository assetMediaRepository = AssetMediaRepository(); @@ -395,6 +399,7 @@ class BackgroundService { exifInfoRepository, userRepository, eTagRepository, + syncRepository, ); UserService userService = UserService( partnerApiRepository, diff --git a/mobile/lib/services/sync.service.dart b/mobile/lib/services/sync.service.dart index f1a6e9b0d7365..fdf563daecef0 100644 --- a/mobile/lib/services/sync.service.dart +++ b/mobile/lib/services/sync.service.dart @@ -12,6 +12,7 @@ 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/user.interface.dart'; import 'package:immich_mobile/repositories/album.repository.dart'; import 'package:immich_mobile/repositories/album_api.repository.dart'; @@ -19,6 +20,7 @@ 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/user.repository.dart'; import 'package:immich_mobile/services/entity.service.dart'; import 'package:immich_mobile/services/hash.service.dart'; @@ -39,6 +41,7 @@ final syncServiceProvider = Provider( ref.watch(exifInfoRepositoryProvider), ref.watch(userRepositoryProvider), ref.watch(etagRepositoryProvider), + ref.watch(syncRepositoryProvider), ), ); @@ -52,6 +55,7 @@ class SyncService { final IExifInfoRepository _exifInfoRepository; final IUserRepository _userRepository; final IETagRepository _eTagRepository; + final ISyncRepository _syncRepository; final AsyncMutex _lock = AsyncMutex(); final Logger _log = Logger('SyncService'); @@ -65,7 +69,40 @@ class SyncService { this._exifInfoRepository, this._userRepository, this._eTagRepository, - ); + this._syncRepository, + ) { + _syncRepository.onAlbumAdded = _onAlbumAdded; + _syncRepository.onAlbumDeleted = _onAlbumDeleted; + _syncRepository.onAlbumUpdated = _onAlbumUpdated; + + _syncRepository.onAssetAdded = _onAssetAdded; + _syncRepository.onAssetDeleted = _onAssetDeleted; + _syncRepository.onAssetUpdated = _onAssetUpdated; + } + + void _onAlbumAdded(Album album) { + // Update record in database + } + + void _onAlbumDeleted(Album album) { + // Update record in database + } + + void _onAlbumUpdated(Album album) { + // Update record in database + } + + void _onAssetAdded(Asset asset) { + // Update record in database + } + + void _onAssetDeleted(Asset asset) { + // Update record in database + } + + void _onAssetUpdated(Asset asset) { + // Update record in database + } // public methods: @@ -834,6 +871,10 @@ class SyncService { return false; } } + + void incrementalSync() {} + + void fullSync() {} } /// Returns a triple(toAdd, toUpdate, toRemove) diff --git a/mobile/test/modules/shared/sync_service_test.dart b/mobile/test/modules/shared/sync_service_test.dart index c85487c7d04da..47015dc06e146 100644 --- a/mobile/test/modules/shared/sync_service_test.dart +++ b/mobile/test/modules/shared/sync_service_test.dart @@ -48,6 +48,7 @@ void main() { final MockAssetRepository assetRepository = MockAssetRepository(); final MockExifInfoRepository exifInfoRepository = MockExifInfoRepository(); final MockUserRepository userRepository = MockUserRepository(); + final MockSyncRepository syncRepository = MockSyncRepository(); final MockETagRepository eTagRepository = MockETagRepository(); final MockAlbumMediaRepository albumMediaRepository = MockAlbumMediaRepository(); @@ -86,6 +87,7 @@ void main() { exifInfoRepository, userRepository, eTagRepository, + syncRepository, ); 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..c9e77202298fd 100644 --- a/mobile/test/repository.mocks.dart +++ b/mobile/test/repository.mocks.dart @@ -7,6 +7,7 @@ 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/user.interface.dart'; import 'package:mocktail/mocktail.dart'; @@ -29,3 +30,5 @@ class MockAssetMediaRepository extends Mock implements IAssetMediaRepository {} class MockFileMediaRepository extends Mock implements IFileMediaRepository {} class MockAlbumApiRepository extends Mock implements IAlbumApiRepository {} + +class MockSyncRepository extends Mock implements ISyncRepository {} From 002ef492f8fdf1691c74b81b309657c194101b29 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 23 Oct 2024 15:06:56 -0500 Subject: [PATCH 02/15] wiring things up --- mobile/lib/pages/search/search.page.dart | 11 +++++++++-- mobile/lib/repositories/sync.repository.dart | 3 +++ mobile/lib/repositories/sync_api.repository.dart | 11 ++++++++++- mobile/lib/services/sync.service.dart | 8 ++++++-- 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/mobile/lib/pages/search/search.page.dart b/mobile/lib/pages/search/search.page.dart index 60e61da4cc5d5..2d724c74fd4b0 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).incrementalSync(), + ), ], ), ); diff --git a/mobile/lib/repositories/sync.repository.dart b/mobile/lib/repositories/sync.repository.dart index 3a8da6944fd0c..c9f86047a4956 100644 --- a/mobile/lib/repositories/sync.repository.dart +++ b/mobile/lib/repositories/sync.repository.dart @@ -48,8 +48,11 @@ class SyncRepository extends DatabaseRepository implements ISyncRepository { @override Future incrementalSync() async { _apiRepository.getChanges().listen((event) async { + print("event: $event"); final type = jsonDecode(event)['type']; final data = jsonDecode(event)['data']; + print("type: $type"); + print("data: $data"); if (type == 'album_added') { final dto = AlbumResponseDto.fromJson(data); diff --git a/mobile/lib/repositories/sync_api.repository.dart b/mobile/lib/repositories/sync_api.repository.dart index 8881593c04fc3..5bac9a5f4fd05 100644 --- a/mobile/lib/repositories/sync_api.repository.dart +++ b/mobile/lib/repositories/sync_api.repository.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/interfaces/sync_api.interface.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/repositories/api.repository.dart'; @@ -19,21 +20,29 @@ class SyncApiRepository extends ApiRepository implements ISyncApiRepository { @override Stream getChanges() async* { - final url = Uri.parse('http://my-api-server.com/stream'); + final serverUrl = Store.get(StoreKey.serverUrl); + final accessToken = Store.get(StoreKey.accessToken); + print("serverUrl: $serverUrl"); + print("accessToken: $accessToken"); + + final url = Uri.parse('$serverUrl/sync'); final client = http.Client(); try { final request = http.Request('GET', url); + request.headers['x-immich-user-token'] = accessToken; final response = await client.send(request); // Read and print the chunks from the response stream await for (var chunk in response.stream.transform(utf8.decoder)) { // Process each chunk as it is received + print("chunk: $chunk"); yield chunk; } } catch (e) { debugPrint("Error: $e"); } finally { + debugPrint("Closing client"); client.close(); } } diff --git a/mobile/lib/services/sync.service.dart b/mobile/lib/services/sync.service.dart index fdf563daecef0..ff9aaebd2da87 100644 --- a/mobile/lib/services/sync.service.dart +++ b/mobile/lib/services/sync.service.dart @@ -872,9 +872,13 @@ class SyncService { } } - void incrementalSync() {} + void incrementalSync() async { + await _syncRepository.incrementalSync(); + } - void fullSync() {} + void fullSync() async { + await _syncRepository.fullSync(); + } } /// Returns a triple(toAdd, toUpdate, toRemove) From 1a06de01b76022b7d36c8c779c915984c44bf18e Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 23 Oct 2024 15:44:54 -0500 Subject: [PATCH 03/15] add mock endpoint --- .../lib/repositories/sync_api.repository.dart | 6 +++++- open-api/immich-openapi-specs.json | 14 +++++++++++++ server/src/controllers/sync.controller.ts | 13 +++++++++++- server/src/services/sync.service.ts | 20 +++++++++++++++++++ 4 files changed, 51 insertions(+), 2 deletions(-) diff --git a/mobile/lib/repositories/sync_api.repository.dart b/mobile/lib/repositories/sync_api.repository.dart index 5bac9a5f4fd05..ccbdd2e6424a0 100644 --- a/mobile/lib/repositories/sync_api.repository.dart +++ b/mobile/lib/repositories/sync_api.repository.dart @@ -29,8 +29,12 @@ class SyncApiRepository extends ApiRepository implements ISyncApiRepository { final client = http.Client(); try { - final request = http.Request('GET', url); + final request = http.Request('POST', url); request.headers['x-immich-user-token'] = accessToken; + final payload = { + 'types': ["asset"], + }; + request.body = jsonEncode(payload); final response = await client.send(request); // Read and print the chunks from the response stream diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index b99da367b8665..e4292c9a14fb3 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -5778,6 +5778,20 @@ ] } }, + "/sync": { + "post": { + "operationId": "sync", + "parameters": [], + "responses": { + "201": { + "description": "" + } + }, + "tags": [ + "Sync" + ] + } + }, "/sync/delta-sync": { "post": { "operationId": "getDeltaSync", diff --git a/server/src/controllers/sync.controller.ts b/server/src/controllers/sync.controller.ts index 4d970a7102a8f..a16124e4e3ad0 100644 --- a/server/src/controllers/sync.controller.ts +++ b/server/src/controllers/sync.controller.ts @@ -1,5 +1,6 @@ -import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common'; +import { Body, Controller, Header, HttpCode, HttpStatus, Post, Res } from '@nestjs/common'; import { 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'; @@ -24,4 +25,14 @@ export class SyncController { getDeltaSync(@Auth() auth: AuthDto, @Body() dto: AssetDeltaSyncDto): Promise { return this.service.getDeltaSync(auth, dto); } + + @Post() + @Header('Content-Type', 'application/jsonlines+json') + sync(@Res() res: Response) { + try { + this.service.sync(res); + } catch { + res.setHeader('Content-Type', 'application/json'); + } + } } diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index f85200db489fa..75bfb1a84d64b 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -1,4 +1,6 @@ import { DateTime } from 'luxon'; +import { Writable } from 'node:stream'; +import { setTimeout } from 'node:timers/promises'; import { AUDIT_LOG_MAX_DURATION } from 'src/constants'; import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; @@ -11,6 +13,24 @@ import { setIsEqual } from 'src/utils/set'; const FULL_SYNC = { needsFullSync: true, deleted: [], upserted: [] }; export class SyncService extends BaseService { + sync(stream: Writable) { + void this.streamWrites(stream); + } + + async streamWrites(stream: Writable) { + for (let i = 0; i < 10; i++) { + const delay = 100; + + console.log(`waiting ${delay}ms`); + + await setTimeout(delay); + + stream.write(JSON.stringify({ id: i, type: 'Test' }) + '\n'); + } + + 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; From 000e37c3ecfc1ca14511bff91a9ccf2ea767be3a Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 24 Oct 2024 09:49:44 -0500 Subject: [PATCH 04/15] parsing response --- mobile/lib/repositories/sync.repository.dart | 3 - .../lib/repositories/sync_api.repository.dart | 78 ++++++++++++++++--- mobile/lib/services/api.service.dart | 9 +-- mobile/lib/services/sync.service.dart | 6 ++ server/src/services/sync.service.ts | 21 ++++- 5 files changed, 90 insertions(+), 27 deletions(-) diff --git a/mobile/lib/repositories/sync.repository.dart b/mobile/lib/repositories/sync.repository.dart index c9f86047a4956..3a8da6944fd0c 100644 --- a/mobile/lib/repositories/sync.repository.dart +++ b/mobile/lib/repositories/sync.repository.dart @@ -48,11 +48,8 @@ class SyncRepository extends DatabaseRepository implements ISyncRepository { @override Future incrementalSync() async { _apiRepository.getChanges().listen((event) async { - print("event: $event"); final type = jsonDecode(event)['type']; final data = jsonDecode(event)['data']; - print("type: $type"); - print("data: $data"); if (type == 'album_added') { final dto = AlbumResponseDto.fromJson(data); diff --git a/mobile/lib/repositories/sync_api.repository.dart b/mobile/lib/repositories/sync_api.repository.dart index ccbdd2e6424a0..b8334e8eb26e2 100644 --- a/mobile/lib/repositories/sync_api.repository.dart +++ b/mobile/lib/repositories/sync_api.repository.dart @@ -1,5 +1,9 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/sync_api.interface.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/repositories/api.repository.dart'; @@ -22,28 +26,27 @@ class SyncApiRepository extends ApiRepository implements ISyncApiRepository { Stream getChanges() async* { final serverUrl = Store.get(StoreKey.serverUrl); final accessToken = Store.get(StoreKey.accessToken); - print("serverUrl: $serverUrl"); - print("accessToken: $accessToken"); final url = Uri.parse('$serverUrl/sync'); final client = http.Client(); + final request = http.Request('POST', url); + + request.headers['x-immich-user-token'] = accessToken; + request.body = jsonEncode({ + 'types': ["asset"], + }); try { - final request = http.Request('POST', url); - request.headers['x-immich-user-token'] = accessToken; - final payload = { - 'types': ["asset"], - }; - request.body = jsonEncode(payload); final response = await client.send(request); - // Read and print the chunks from the response stream await for (var chunk in response.stream.transform(utf8.decoder)) { - // Process each chunk as it is received - print("chunk: $chunk"); + // final data = await compute(_parseSyncReponse, chunk); + final data = _parseSyncReponse(chunk); + print("Data: $data"); yield chunk; } - } catch (e) { + } catch (e, stack) { + print(stack); debugPrint("Error: $e"); } finally { debugPrint("Closing client"); @@ -56,4 +59,55 @@ class SyncApiRepository extends ApiRepository implements ISyncApiRepository { // TODO: implement confirmChages throw UnimplementedError(); } + + Map _parseSyncReponse(String jsonString) { + final type = jsonDecode(jsonString)['type']; + final data = jsonDecode(jsonString)['data']; + final action = jsonDecode(jsonString)['action']; + + switch (type) { + case 'asset': + if (action == 'upsert') { + return {type: AssetResponseDto.fromJson(data)}; + } + + if (action == 'delete') { + return {type: data}; + } + + case 'album': + final dto = AlbumResponseDto.fromJson(data); + if (dto == null) { + return {}; + } + + final album = Album.remote(dto); + return {type: album}; + + case 'albumAsset': //AlbumAssetResponseDto + // final dto = AlbumAssetResponseDto.fromJson(data); + // final album = Album.remote(dto!); + break; + + case 'user': + final dto = UserResponseDto.fromJson(data); + if (dto == null) { + return {}; + } + + final user = User.fromSimpleUserDto(dto); + return {type: user}; + + case 'partner': + final dto = PartnerResponseDto.fromJson(data); + if (dto == null) { + return {}; + } + + final partner = User.fromPartnerDto(dto); + return {type: partner}; + } + + return {"invalid": null}; + } } 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/sync.service.dart b/mobile/lib/services/sync.service.dart index ff9aaebd2da87..6c8027363c0d2 100644 --- a/mobile/lib/services/sync.service.dart +++ b/mobile/lib/services/sync.service.dart @@ -82,26 +82,32 @@ class SyncService { void _onAlbumAdded(Album album) { // Update record in database + // print("_onAlbumAdded: $album"); } void _onAlbumDeleted(Album album) { // Update record in database + print("Album deleted: $album"); } void _onAlbumUpdated(Album album) { // Update record in database + print("Album updated: $album"); } void _onAssetAdded(Asset asset) { // Update record in database + // print("Asset added: $asset"); } void _onAssetDeleted(Asset asset) { // Update record in database + print("Asset deleted: $asset"); } void _onAssetUpdated(Asset asset) { // Update record in database + print("Asset updated: $asset"); } // public methods: diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index 75bfb1a84d64b..25f0dfb617867 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -2,6 +2,7 @@ import { DateTime } from 'luxon'; import { Writable } from 'node:stream'; import { setTimeout } from 'node:timers/promises'; import { AUDIT_LOG_MAX_DURATION } from 'src/constants'; +import { AlbumResponseDto, mapAlbum } 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'; @@ -13,11 +14,23 @@ import { setIsEqual } from 'src/utils/set'; const FULL_SYNC = { needsFullSync: true, deleted: [], upserted: [] }; export class SyncService extends BaseService { - sync(stream: Writable) { - void this.streamWrites(stream); + async sync(stream: Writable) { + const a = await this.albumRepository.getById('7e98d5f4-5f21-4704-b3a7-1d001e3728d1', { withAssets: false }); + if (!a) { + return; + } + const b = await this.assetRepository.getById('9901daee-90a2-4d97-811f-91d78d65bc6a'); + if (!b) { + return; + } + + const album = mapAlbum(a, false); + const asset = mapAsset(b); + void this.streamWrites(stream, album, 'album'); + void this.streamWrites(stream, asset, 'asset'); } - async streamWrites(stream: Writable) { + async streamWrites(stream: Writable, a: AlbumResponseDto | AssetResponseDto, type: 'asset' | 'album') { for (let i = 0; i < 10; i++) { const delay = 100; @@ -25,7 +38,7 @@ export class SyncService extends BaseService { await setTimeout(delay); - stream.write(JSON.stringify({ id: i, type: 'Test' }) + '\n'); + stream.write(JSON.stringify({ id: i, type, data: a }) + '\n'); } stream.end(); From a17d4b031173995e2d30d85e371838042ce7221c Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 23 Oct 2024 17:21:28 -0400 Subject: [PATCH 05/15] feat: better mobile sync --- e2e/src/api/specs/sync.e2e-spec.ts | 83 ++ e2e/src/fixtures.ts | 1 + mobile/openapi/README.md | 10 + mobile/openapi/lib/api.dart | 8 + mobile/openapi/lib/api/sync_api.dart | 89 ++ mobile/openapi/lib/api_client.dart | 16 + mobile/openapi/lib/api_helper.dart | 6 + .../lib/model/album_asset_response_dto.dart | 107 +++ .../lib/model/sync_acknowledge_dto.dart | 329 +++++++ mobile/openapi/lib/model/sync_action.dart | 85 ++ .../lib/model/sync_checkpoint_dto.dart | 107 +++ mobile/openapi/lib/model/sync_stream_dto.dart | 209 +++++ .../lib/model/sync_stream_response_dto.dart | 115 +++ .../model/sync_stream_response_dto_data.dart | 830 ++++++++++++++++++ mobile/openapi/lib/model/sync_type.dart | 121 +++ open-api/immich-openapi-specs.json | 266 ++++++ open-api/typescript-sdk/src/fetch-client.ts | 73 ++ server/src/app.module.ts | 2 +- server/src/controllers/sync.controller.ts | 45 +- server/src/dtos/album-asset.dto.ts | 9 + server/src/dtos/sync.dto.ts | 139 ++- server/src/entities/index.ts | 2 + .../src/entities/session-sync-state.entity.ts | 52 ++ server/src/enum.ts | 22 + server/src/interfaces/sync.interface.ts | 43 + .../src/middleware/global-exception.filter.ts | 12 +- .../1729792220961-AddSessionStateTable.ts | 16 + server/src/repositories/index.ts | 3 + server/src/repositories/sync.repository.ts | 104 +++ server/src/services/base.service.ts | 2 + server/src/services/sync.service.ts | 180 +++- server/src/utils/misc.ts | 2 + .../test/repositories/sync.repository.mock.ts | 13 + server/test/utils.ts | 4 + web/src/routes/+layout.svelte | 28 + 35 files changed, 3117 insertions(+), 16 deletions(-) create mode 100644 e2e/src/api/specs/sync.e2e-spec.ts create mode 100644 mobile/openapi/lib/model/album_asset_response_dto.dart create mode 100644 mobile/openapi/lib/model/sync_acknowledge_dto.dart create mode 100644 mobile/openapi/lib/model/sync_action.dart create mode 100644 mobile/openapi/lib/model/sync_checkpoint_dto.dart create mode 100644 mobile/openapi/lib/model/sync_stream_dto.dart create mode 100644 mobile/openapi/lib/model/sync_stream_response_dto.dart create mode 100644 mobile/openapi/lib/model/sync_stream_response_dto_data.dart create mode 100644 mobile/openapi/lib/model/sync_type.dart create mode 100644 server/src/dtos/album-asset.dto.ts create mode 100644 server/src/entities/session-sync-state.entity.ts create mode 100644 server/src/interfaces/sync.interface.ts create mode 100644 server/src/migrations/1729792220961-AddSessionStateTable.ts create mode 100644 server/src/repositories/sync.repository.ts create mode 100644 server/test/repositories/sync.repository.mock.ts 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/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/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..c54c7e32b4b37 --- /dev/null +++ b/server/src/repositories/sync.repository.ts @@ -0,0 +1,104 @@ +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 }, 'updatedAt', checkpoint), + 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 8f7f372efd266..728438ff5dfa0 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(); } From e82c0cda2591f979cd1e26a2964f40550182c944 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 24 Oct 2024 15:51:27 -0500 Subject: [PATCH 06/15] update --- open-api/immich-openapi-specs.json | 1612 ++++++++++++++++++++++------ 1 file changed, 1261 insertions(+), 351 deletions(-) diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 5ca4261ad5c42..13dacc7c70943 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -75,7 +75,9 @@ "api_key": [] } ], - "tags": ["Activities"] + "tags": [ + "Activities" + ] }, "post": { "operationId": "createActivity", @@ -113,7 +115,9 @@ "api_key": [] } ], - "tags": ["Activities"] + "tags": [ + "Activities" + ] } }, "/activities/statistics": { @@ -162,7 +166,9 @@ "api_key": [] } ], - "tags": ["Activities"] + "tags": [ + "Activities" + ] } }, "/activities/{id}": { @@ -195,7 +201,9 @@ "api_key": [] } ], - "tags": ["Activities"] + "tags": [ + "Activities" + ] } }, "/admin/users": { @@ -237,7 +245,9 @@ "api_key": [] } ], - "tags": ["Users (admin)"] + "tags": [ + "Users (admin)" + ] }, "post": { "operationId": "createUserAdmin", @@ -275,7 +285,9 @@ "api_key": [] } ], - "tags": ["Users (admin)"] + "tags": [ + "Users (admin)" + ] } }, "/admin/users/{id}": { @@ -325,7 +337,9 @@ "api_key": [] } ], - "tags": ["Users (admin)"] + "tags": [ + "Users (admin)" + ] }, "get": { "operationId": "getUserAdmin", @@ -363,7 +377,9 @@ "api_key": [] } ], - "tags": ["Users (admin)"] + "tags": [ + "Users (admin)" + ] }, "put": { "operationId": "updateUserAdmin", @@ -411,7 +427,9 @@ "api_key": [] } ], - "tags": ["Users (admin)"] + "tags": [ + "Users (admin)" + ] } }, "/admin/users/{id}/preferences": { @@ -451,7 +469,9 @@ "api_key": [] } ], - "tags": ["Users (admin)"] + "tags": [ + "Users (admin)" + ] }, "put": { "operationId": "updateUserPreferencesAdmin", @@ -499,7 +519,9 @@ "api_key": [] } ], - "tags": ["Users (admin)"] + "tags": [ + "Users (admin)" + ] } }, "/admin/users/{id}/restore": { @@ -539,7 +561,9 @@ "api_key": [] } ], - "tags": ["Users (admin)"] + "tags": [ + "Users (admin)" + ] } }, "/albums": { @@ -591,7 +615,9 @@ "api_key": [] } ], - "tags": ["Albums"] + "tags": [ + "Albums" + ] }, "post": { "operationId": "createAlbum", @@ -629,7 +655,9 @@ "api_key": [] } ], - "tags": ["Albums"] + "tags": [ + "Albums" + ] } }, "/albums/statistics": { @@ -659,7 +687,9 @@ "api_key": [] } ], - "tags": ["Albums"] + "tags": [ + "Albums" + ] } }, "/albums/{id}": { @@ -692,7 +722,9 @@ "api_key": [] } ], - "tags": ["Albums"] + "tags": [ + "Albums" + ] }, "get": { "operationId": "getAlbumInfo", @@ -746,7 +778,9 @@ "api_key": [] } ], - "tags": ["Albums"] + "tags": [ + "Albums" + ] }, "patch": { "operationId": "updateAlbumInfo", @@ -794,7 +828,9 @@ "api_key": [] } ], - "tags": ["Albums"] + "tags": [ + "Albums" + ] } }, "/albums/{id}/assets": { @@ -847,7 +883,9 @@ "api_key": [] } ], - "tags": ["Albums"] + "tags": [ + "Albums" + ] }, "put": { "operationId": "addAssetsToAlbum", @@ -906,7 +944,9 @@ "api_key": [] } ], - "tags": ["Albums"] + "tags": [ + "Albums" + ] } }, "/albums/{id}/user/{userId}": { @@ -947,7 +987,9 @@ "api_key": [] } ], - "tags": ["Albums"] + "tags": [ + "Albums" + ] }, "put": { "operationId": "updateAlbumUser", @@ -996,7 +1038,9 @@ "api_key": [] } ], - "tags": ["Albums"] + "tags": [ + "Albums" + ] } }, "/albums/{id}/users": { @@ -1046,7 +1090,9 @@ "api_key": [] } ], - "tags": ["Albums"] + "tags": [ + "Albums" + ] } }, "/api-keys": { @@ -1079,7 +1125,9 @@ "api_key": [] } ], - "tags": ["API Keys"] + "tags": [ + "API Keys" + ] }, "post": { "operationId": "createApiKey", @@ -1117,7 +1165,9 @@ "api_key": [] } ], - "tags": ["API Keys"] + "tags": [ + "API Keys" + ] } }, "/api-keys/{id}": { @@ -1150,7 +1200,9 @@ "api_key": [] } ], - "tags": ["API Keys"] + "tags": [ + "API Keys" + ] }, "get": { "operationId": "getApiKey", @@ -1188,7 +1240,9 @@ "api_key": [] } ], - "tags": ["API Keys"] + "tags": [ + "API Keys" + ] }, "put": { "operationId": "updateApiKey", @@ -1236,7 +1290,9 @@ "api_key": [] } ], - "tags": ["API Keys"] + "tags": [ + "API Keys" + ] } }, "/assets": { @@ -1269,7 +1325,9 @@ "api_key": [] } ], - "tags": ["Assets"] + "tags": [ + "Assets" + ] }, "post": { "operationId": "uploadAsset", @@ -1326,7 +1384,9 @@ "api_key": [] } ], - "tags": ["Assets"] + "tags": [ + "Assets" + ] }, "put": { "operationId": "updateAssets", @@ -1357,7 +1417,9 @@ "api_key": [] } ], - "tags": ["Assets"] + "tags": [ + "Assets" + ] } }, "/assets/bulk-upload-check": { @@ -1398,7 +1460,9 @@ "api_key": [] } ], - "tags": ["Assets"] + "tags": [ + "Assets" + ] } }, "/assets/device/{deviceId}": { @@ -1441,7 +1505,9 @@ "api_key": [] } ], - "tags": ["Assets"] + "tags": [ + "Assets" + ] } }, "/assets/exist": { @@ -1482,7 +1548,9 @@ "api_key": [] } ], - "tags": ["Assets"] + "tags": [ + "Assets" + ] } }, "/assets/jobs": { @@ -1515,7 +1583,9 @@ "api_key": [] } ], - "tags": ["Assets"] + "tags": [ + "Assets" + ] } }, "/assets/memory-lane": { @@ -1569,7 +1639,9 @@ "api_key": [] } ], - "tags": ["Assets"] + "tags": [ + "Assets" + ] } }, "/assets/random": { @@ -1614,7 +1686,10 @@ "api_key": [] } ], - "tags": ["Assets", "Deprecated"], + "tags": [ + "Assets", + "Deprecated" + ], "x-immich-lifecycle": { "deprecatedAt": "v1.116.0" } @@ -1672,7 +1747,9 @@ "api_key": [] } ], - "tags": ["Assets"] + "tags": [ + "Assets" + ] } }, "/assets/{id}": { @@ -1720,7 +1797,9 @@ "api_key": [] } ], - "tags": ["Assets"] + "tags": [ + "Assets" + ] }, "put": { "operationId": "updateAsset", @@ -1768,7 +1847,9 @@ "api_key": [] } ], - "tags": ["Assets"] + "tags": [ + "Assets" + ] } }, "/assets/{id}/original": { @@ -1817,7 +1898,9 @@ "api_key": [] } ], - "tags": ["Assets"] + "tags": [ + "Assets" + ] }, "put": { "description": "Replace the asset with new file, without changing its id", @@ -1874,7 +1957,9 @@ "api_key": [] } ], - "tags": ["Assets"], + "tags": [ + "Assets" + ], "x-immich-lifecycle": { "addedAt": "v1.106.0" } @@ -1934,7 +2019,9 @@ "api_key": [] } ], - "tags": ["Assets"] + "tags": [ + "Assets" + ] } }, "/assets/{id}/video/playback": { @@ -1983,7 +2070,9 @@ "api_key": [] } ], - "tags": ["Assets"] + "tags": [ + "Assets" + ] } }, "/audit/deletes": { @@ -2040,7 +2129,9 @@ "api_key": [] } ], - "tags": ["Audit"] + "tags": [ + "Audit" + ] } }, "/auth/admin-sign-up": { @@ -2069,7 +2160,9 @@ "description": "" } }, - "tags": ["Authentication"] + "tags": [ + "Authentication" + ] } }, "/auth/change-password": { @@ -2109,7 +2202,9 @@ "api_key": [] } ], - "tags": ["Authentication"] + "tags": [ + "Authentication" + ] } }, "/auth/login": { @@ -2138,7 +2233,9 @@ "description": "" } }, - "tags": ["Authentication"] + "tags": [ + "Authentication" + ] } }, "/auth/logout": { @@ -2168,7 +2265,9 @@ "api_key": [] } ], - "tags": ["Authentication"] + "tags": [ + "Authentication" + ] } }, "/auth/validateToken": { @@ -2198,7 +2297,9 @@ "api_key": [] } ], - "tags": ["Authentication"] + "tags": [ + "Authentication" + ] } }, "/download/archive": { @@ -2248,7 +2349,9 @@ "api_key": [] } ], - "tags": ["Download"] + "tags": [ + "Download" + ] } }, "/download/info": { @@ -2297,7 +2400,9 @@ "api_key": [] } ], - "tags": ["Download"] + "tags": [ + "Download" + ] } }, "/duplicates": { @@ -2330,7 +2435,9 @@ "api_key": [] } ], - "tags": ["Duplicates"] + "tags": [ + "Duplicates" + ] } }, "/faces": { @@ -2373,7 +2480,9 @@ "api_key": [] } ], - "tags": ["Faces"] + "tags": [ + "Faces" + ] } }, "/faces/{id}": { @@ -2423,7 +2532,9 @@ "api_key": [] } ], - "tags": ["Faces"] + "tags": [ + "Faces" + ] } }, "/jobs": { @@ -2453,7 +2564,9 @@ "api_key": [] } ], - "tags": ["Jobs"] + "tags": [ + "Jobs" + ] }, "post": { "operationId": "createJob", @@ -2484,7 +2597,9 @@ "api_key": [] } ], - "tags": ["Jobs"] + "tags": [ + "Jobs" + ] } }, "/jobs/{id}": { @@ -2533,7 +2648,9 @@ "api_key": [] } ], - "tags": ["Jobs"] + "tags": [ + "Jobs" + ] } }, "/libraries": { @@ -2566,7 +2683,9 @@ "api_key": [] } ], - "tags": ["Libraries"] + "tags": [ + "Libraries" + ] }, "post": { "operationId": "createLibrary", @@ -2604,7 +2723,9 @@ "api_key": [] } ], - "tags": ["Libraries"] + "tags": [ + "Libraries" + ] } }, "/libraries/{id}": { @@ -2637,7 +2758,9 @@ "api_key": [] } ], - "tags": ["Libraries"] + "tags": [ + "Libraries" + ] }, "get": { "operationId": "getLibrary", @@ -2675,7 +2798,9 @@ "api_key": [] } ], - "tags": ["Libraries"] + "tags": [ + "Libraries" + ] }, "put": { "operationId": "updateLibrary", @@ -2723,7 +2848,9 @@ "api_key": [] } ], - "tags": ["Libraries"] + "tags": [ + "Libraries" + ] } }, "/libraries/{id}/scan": { @@ -2756,7 +2883,9 @@ "api_key": [] } ], - "tags": ["Libraries"] + "tags": [ + "Libraries" + ] } }, "/libraries/{id}/statistics": { @@ -2796,7 +2925,9 @@ "api_key": [] } ], - "tags": ["Libraries"] + "tags": [ + "Libraries" + ] } }, "/libraries/{id}/validate": { @@ -2846,7 +2977,9 @@ "api_key": [] } ], - "tags": ["Libraries"] + "tags": [ + "Libraries" + ] } }, "/map/markers": { @@ -2930,7 +3063,9 @@ "api_key": [] } ], - "tags": ["Map"] + "tags": [ + "Map" + ] } }, "/map/reverse-geocode": { @@ -2982,7 +3117,9 @@ "api_key": [] } ], - "tags": ["Map"] + "tags": [ + "Map" + ] } }, "/memories": { @@ -3015,7 +3152,9 @@ "api_key": [] } ], - "tags": ["Memories"] + "tags": [ + "Memories" + ] }, "post": { "operationId": "createMemory", @@ -3053,7 +3192,9 @@ "api_key": [] } ], - "tags": ["Memories"] + "tags": [ + "Memories" + ] } }, "/memories/{id}": { @@ -3086,7 +3227,9 @@ "api_key": [] } ], - "tags": ["Memories"] + "tags": [ + "Memories" + ] }, "get": { "operationId": "getMemory", @@ -3124,7 +3267,9 @@ "api_key": [] } ], - "tags": ["Memories"] + "tags": [ + "Memories" + ] }, "put": { "operationId": "updateMemory", @@ -3172,7 +3317,9 @@ "api_key": [] } ], - "tags": ["Memories"] + "tags": [ + "Memories" + ] } }, "/memories/{id}/assets": { @@ -3225,7 +3372,9 @@ "api_key": [] } ], - "tags": ["Memories"] + "tags": [ + "Memories" + ] }, "put": { "operationId": "addMemoryAssets", @@ -3276,7 +3425,9 @@ "api_key": [] } ], - "tags": ["Memories"] + "tags": [ + "Memories" + ] } }, "/notifications/test-email": { @@ -3316,7 +3467,9 @@ "api_key": [] } ], - "tags": ["Notifications"] + "tags": [ + "Notifications" + ] } }, "/oauth/authorize": { @@ -3345,7 +3498,9 @@ "description": "" } }, - "tags": ["OAuth"] + "tags": [ + "OAuth" + ] } }, "/oauth/callback": { @@ -3374,7 +3529,9 @@ "description": "" } }, - "tags": ["OAuth"] + "tags": [ + "OAuth" + ] } }, "/oauth/link": { @@ -3414,7 +3571,9 @@ "api_key": [] } ], - "tags": ["OAuth"] + "tags": [ + "OAuth" + ] } }, "/oauth/mobile-redirect": { @@ -3426,7 +3585,9 @@ "description": "" } }, - "tags": ["OAuth"] + "tags": [ + "OAuth" + ] } }, "/oauth/unlink": { @@ -3456,7 +3617,9 @@ "api_key": [] } ], - "tags": ["OAuth"] + "tags": [ + "OAuth" + ] } }, "/partners": { @@ -3498,7 +3661,9 @@ "api_key": [] } ], - "tags": ["Partners"] + "tags": [ + "Partners" + ] } }, "/partners/{id}": { @@ -3531,7 +3696,9 @@ "api_key": [] } ], - "tags": ["Partners"] + "tags": [ + "Partners" + ] }, "post": { "operationId": "createPartner", @@ -3569,7 +3736,9 @@ "api_key": [] } ], - "tags": ["Partners"] + "tags": [ + "Partners" + ] }, "put": { "operationId": "updatePartner", @@ -3617,7 +3786,9 @@ "api_key": [] } ], - "tags": ["Partners"] + "tags": [ + "Partners" + ] } }, "/people": { @@ -3679,7 +3850,9 @@ "api_key": [] } ], - "tags": ["People"] + "tags": [ + "People" + ] }, "post": { "operationId": "createPerson", @@ -3717,7 +3890,9 @@ "api_key": [] } ], - "tags": ["People"] + "tags": [ + "People" + ] }, "put": { "operationId": "updatePeople", @@ -3758,7 +3933,9 @@ "api_key": [] } ], - "tags": ["People"] + "tags": [ + "People" + ] } }, "/people/{id}": { @@ -3798,7 +3975,9 @@ "api_key": [] } ], - "tags": ["People"] + "tags": [ + "People" + ] }, "put": { "operationId": "updatePerson", @@ -3846,7 +4025,9 @@ "api_key": [] } ], - "tags": ["People"] + "tags": [ + "People" + ] } }, "/people/{id}/merge": { @@ -3899,7 +4080,9 @@ "api_key": [] } ], - "tags": ["People"] + "tags": [ + "People" + ] } }, "/people/{id}/reassign": { @@ -3952,7 +4135,9 @@ "api_key": [] } ], - "tags": ["People"] + "tags": [ + "People" + ] } }, "/people/{id}/statistics": { @@ -3992,7 +4177,9 @@ "api_key": [] } ], - "tags": ["People"] + "tags": [ + "People" + ] } }, "/people/{id}/thumbnail": { @@ -4033,7 +4220,9 @@ "api_key": [] } ], - "tags": ["People"] + "tags": [ + "People" + ] } }, "/reports": { @@ -4063,7 +4252,9 @@ "api_key": [] } ], - "tags": ["File Reports"] + "tags": [ + "File Reports" + ] } }, "/reports/checksum": { @@ -4106,7 +4297,9 @@ "api_key": [] } ], - "tags": ["File Reports"] + "tags": [ + "File Reports" + ] } }, "/reports/fix": { @@ -4139,7 +4332,9 @@ "api_key": [] } ], - "tags": ["File Reports"] + "tags": [ + "File Reports" + ] } }, "/search/cities": { @@ -4172,7 +4367,9 @@ "api_key": [] } ], - "tags": ["Search"] + "tags": [ + "Search" + ] } }, "/search/explore": { @@ -4205,7 +4402,9 @@ "api_key": [] } ], - "tags": ["Search"] + "tags": [ + "Search" + ] } }, "/search/metadata": { @@ -4245,7 +4444,9 @@ "api_key": [] } ], - "tags": ["Search"] + "tags": [ + "Search" + ] } }, "/search/person": { @@ -4295,7 +4496,9 @@ "api_key": [] } ], - "tags": ["Search"] + "tags": [ + "Search" + ] } }, "/search/places": { @@ -4337,7 +4540,9 @@ "api_key": [] } ], - "tags": ["Search"] + "tags": [ + "Search" + ] } }, "/search/random": { @@ -4380,7 +4585,9 @@ "api_key": [] } ], - "tags": ["Search"] + "tags": [ + "Search" + ] } }, "/search/smart": { @@ -4420,7 +4627,9 @@ "api_key": [] } ], - "tags": ["Search"] + "tags": [ + "Search" + ] } }, "/search/suggestions": { @@ -4503,7 +4712,9 @@ "api_key": [] } ], - "tags": ["Search"] + "tags": [ + "Search" + ] } }, "/server/about": { @@ -4533,7 +4744,9 @@ "api_key": [] } ], - "tags": ["Server"] + "tags": [ + "Server" + ] } }, "/server/config": { @@ -4552,7 +4765,9 @@ "description": "" } }, - "tags": ["Server"] + "tags": [ + "Server" + ] } }, "/server/features": { @@ -4571,7 +4786,9 @@ "description": "" } }, - "tags": ["Server"] + "tags": [ + "Server" + ] } }, "/server/license": { @@ -4594,7 +4811,9 @@ "api_key": [] } ], - "tags": ["Server"] + "tags": [ + "Server" + ] }, "get": { "operationId": "getServerLicense", @@ -4625,7 +4844,9 @@ "api_key": [] } ], - "tags": ["Server"] + "tags": [ + "Server" + ] }, "put": { "operationId": "setServerLicense", @@ -4663,7 +4884,9 @@ "api_key": [] } ], - "tags": ["Server"] + "tags": [ + "Server" + ] } }, "/server/media-types": { @@ -4682,7 +4905,9 @@ "description": "" } }, - "tags": ["Server"] + "tags": [ + "Server" + ] } }, "/server/ping": { @@ -4701,7 +4926,9 @@ "description": "" } }, - "tags": ["Server"] + "tags": [ + "Server" + ] } }, "/server/statistics": { @@ -4731,7 +4958,9 @@ "api_key": [] } ], - "tags": ["Server"] + "tags": [ + "Server" + ] } }, "/server/storage": { @@ -4761,7 +4990,9 @@ "api_key": [] } ], - "tags": ["Server"] + "tags": [ + "Server" + ] } }, "/server/theme": { @@ -4780,7 +5011,9 @@ "description": "" } }, - "tags": ["Server"] + "tags": [ + "Server" + ] } }, "/server/version": { @@ -4799,7 +5032,9 @@ "description": "" } }, - "tags": ["Server"] + "tags": [ + "Server" + ] } }, "/server/version-history": { @@ -4821,7 +5056,9 @@ "description": "" } }, - "tags": ["Server"] + "tags": [ + "Server" + ] } }, "/sessions": { @@ -4844,7 +5081,9 @@ "api_key": [] } ], - "tags": ["Sessions"] + "tags": [ + "Sessions" + ] }, "get": { "operationId": "getSessions", @@ -4875,7 +5114,9 @@ "api_key": [] } ], - "tags": ["Sessions"] + "tags": [ + "Sessions" + ] } }, "/sessions/{id}": { @@ -4908,7 +5149,9 @@ "api_key": [] } ], - "tags": ["Sessions"] + "tags": [ + "Sessions" + ] } }, "/shared-links": { @@ -4941,7 +5184,9 @@ "api_key": [] } ], - "tags": ["Shared Links"] + "tags": [ + "Shared Links" + ] }, "post": { "operationId": "createSharedLink", @@ -4979,7 +5224,9 @@ "api_key": [] } ], - "tags": ["Shared Links"] + "tags": [ + "Shared Links" + ] } }, "/shared-links/me": { @@ -5035,7 +5282,9 @@ "api_key": [] } ], - "tags": ["Shared Links"] + "tags": [ + "Shared Links" + ] } }, "/shared-links/{id}": { @@ -5068,7 +5317,9 @@ "api_key": [] } ], - "tags": ["Shared Links"] + "tags": [ + "Shared Links" + ] }, "get": { "operationId": "getSharedLinkById", @@ -5106,7 +5357,9 @@ "api_key": [] } ], - "tags": ["Shared Links"] + "tags": [ + "Shared Links" + ] }, "patch": { "operationId": "updateSharedLink", @@ -5154,7 +5407,9 @@ "api_key": [] } ], - "tags": ["Shared Links"] + "tags": [ + "Shared Links" + ] } }, "/shared-links/{id}/assets": { @@ -5215,7 +5470,9 @@ "api_key": [] } ], - "tags": ["Shared Links"] + "tags": [ + "Shared Links" + ] }, "put": { "operationId": "addSharedLinkAssets", @@ -5274,7 +5531,9 @@ "api_key": [] } ], - "tags": ["Shared Links"] + "tags": [ + "Shared Links" + ] } }, "/stacks": { @@ -5307,7 +5566,9 @@ "api_key": [] } ], - "tags": ["Stacks"] + "tags": [ + "Stacks" + ] }, "get": { "operationId": "searchStacks", @@ -5347,7 +5608,9 @@ "api_key": [] } ], - "tags": ["Stacks"] + "tags": [ + "Stacks" + ] }, "post": { "operationId": "createStack", @@ -5385,7 +5648,9 @@ "api_key": [] } ], - "tags": ["Stacks"] + "tags": [ + "Stacks" + ] } }, "/stacks/{id}": { @@ -5418,7 +5683,9 @@ "api_key": [] } ], - "tags": ["Stacks"] + "tags": [ + "Stacks" + ] }, "get": { "operationId": "getStack", @@ -5456,7 +5723,9 @@ "api_key": [] } ], - "tags": ["Stacks"] + "tags": [ + "Stacks" + ] }, "put": { "operationId": "updateStack", @@ -5504,7 +5773,9 @@ "api_key": [] } ], - "tags": ["Stacks"] + "tags": [ + "Stacks" + ] } }, "/sync/acknowledge": { @@ -5537,7 +5808,9 @@ "api_key": [] } ], - "tags": ["Sync"] + "tags": [ + "Sync" + ] } }, "/sync/delta-sync": { @@ -5577,7 +5850,9 @@ "api_key": [] } ], - "tags": ["Sync"] + "tags": [ + "Sync" + ] } }, "/sync/full-sync": { @@ -5620,7 +5895,9 @@ "api_key": [] } ], - "tags": ["Sync"] + "tags": [ + "Sync" + ] } }, "/sync/stream": { @@ -5663,7 +5940,9 @@ "api_key": [] } ], - "tags": ["Sync"] + "tags": [ + "Sync" + ] } }, "/system-config": { @@ -5693,7 +5972,9 @@ "api_key": [] } ], - "tags": ["System Config"] + "tags": [ + "System Config" + ] }, "put": { "operationId": "updateConfig", @@ -5731,7 +6012,9 @@ "api_key": [] } ], - "tags": ["System Config"] + "tags": [ + "System Config" + ] } }, "/system-config/defaults": { @@ -5761,7 +6044,9 @@ "api_key": [] } ], - "tags": ["System Config"] + "tags": [ + "System Config" + ] } }, "/system-config/storage-template-options": { @@ -5791,7 +6076,9 @@ "api_key": [] } ], - "tags": ["System Config"] + "tags": [ + "System Config" + ] } }, "/system-metadata/admin-onboarding": { @@ -5821,7 +6108,9 @@ "api_key": [] } ], - "tags": ["System Metadata"] + "tags": [ + "System Metadata" + ] }, "post": { "operationId": "updateAdminOnboarding", @@ -5852,7 +6141,9 @@ "api_key": [] } ], - "tags": ["System Metadata"] + "tags": [ + "System Metadata" + ] } }, "/system-metadata/reverse-geocoding-state": { @@ -5882,7 +6173,9 @@ "api_key": [] } ], - "tags": ["System Metadata"] + "tags": [ + "System Metadata" + ] } }, "/tags": { @@ -5915,7 +6208,9 @@ "api_key": [] } ], - "tags": ["Tags"] + "tags": [ + "Tags" + ] }, "post": { "operationId": "createTag", @@ -5953,7 +6248,9 @@ "api_key": [] } ], - "tags": ["Tags"] + "tags": [ + "Tags" + ] }, "put": { "operationId": "upsertTags", @@ -5994,7 +6291,9 @@ "api_key": [] } ], - "tags": ["Tags"] + "tags": [ + "Tags" + ] } }, "/tags/assets": { @@ -6034,7 +6333,9 @@ "api_key": [] } ], - "tags": ["Tags"] + "tags": [ + "Tags" + ] } }, "/tags/{id}": { @@ -6067,7 +6368,9 @@ "api_key": [] } ], - "tags": ["Tags"] + "tags": [ + "Tags" + ] }, "get": { "operationId": "getTagById", @@ -6105,7 +6408,9 @@ "api_key": [] } ], - "tags": ["Tags"] + "tags": [ + "Tags" + ] }, "put": { "operationId": "updateTag", @@ -6153,7 +6458,9 @@ "api_key": [] } ], - "tags": ["Tags"] + "tags": [ + "Tags" + ] } }, "/tags/{id}/assets": { @@ -6206,7 +6513,9 @@ "api_key": [] } ], - "tags": ["Tags"] + "tags": [ + "Tags" + ] }, "put": { "operationId": "tagAssets", @@ -6257,7 +6566,9 @@ "api_key": [] } ], - "tags": ["Tags"] + "tags": [ + "Tags" + ] } }, "/timeline/bucket": { @@ -6399,7 +6710,9 @@ "api_key": [] } ], - "tags": ["Timeline"] + "tags": [ + "Timeline" + ] } }, "/timeline/buckets": { @@ -6533,7 +6846,9 @@ "api_key": [] } ], - "tags": ["Timeline"] + "tags": [ + "Timeline" + ] } }, "/trash/empty": { @@ -6563,7 +6878,9 @@ "api_key": [] } ], - "tags": ["Trash"] + "tags": [ + "Trash" + ] } }, "/trash/restore": { @@ -6593,7 +6910,9 @@ "api_key": [] } ], - "tags": ["Trash"] + "tags": [ + "Trash" + ] } }, "/trash/restore/assets": { @@ -6633,7 +6952,9 @@ "api_key": [] } ], - "tags": ["Trash"] + "tags": [ + "Trash" + ] } }, "/users": { @@ -6666,7 +6987,9 @@ "api_key": [] } ], - "tags": ["Users"] + "tags": [ + "Users" + ] } }, "/users/me": { @@ -6696,7 +7019,9 @@ "api_key": [] } ], - "tags": ["Users"] + "tags": [ + "Users" + ] }, "put": { "operationId": "updateMyUser", @@ -6734,7 +7059,9 @@ "api_key": [] } ], - "tags": ["Users"] + "tags": [ + "Users" + ] } }, "/users/me/license": { @@ -6757,7 +7084,9 @@ "api_key": [] } ], - "tags": ["Users"] + "tags": [ + "Users" + ] }, "get": { "operationId": "getUserLicense", @@ -6785,7 +7114,9 @@ "api_key": [] } ], - "tags": ["Users"] + "tags": [ + "Users" + ] }, "put": { "operationId": "setUserLicense", @@ -6823,7 +7154,9 @@ "api_key": [] } ], - "tags": ["Users"] + "tags": [ + "Users" + ] } }, "/users/me/preferences": { @@ -6853,7 +7186,9 @@ "api_key": [] } ], - "tags": ["Users"] + "tags": [ + "Users" + ] }, "put": { "operationId": "updateMyPreferences", @@ -6891,7 +7226,9 @@ "api_key": [] } ], - "tags": ["Users"] + "tags": [ + "Users" + ] } }, "/users/profile-image": { @@ -6914,7 +7251,9 @@ "api_key": [] } ], - "tags": ["Users"] + "tags": [ + "Users" + ] }, "post": { "operationId": "createProfileImage", @@ -6953,7 +7292,9 @@ "api_key": [] } ], - "tags": ["Users"] + "tags": [ + "Users" + ] } }, "/users/{id}": { @@ -6993,7 +7334,9 @@ "api_key": [] } ], - "tags": ["Users"] + "tags": [ + "Users" + ] } }, "/users/{id}/profile-image": { @@ -7034,7 +7377,9 @@ "api_key": [] } ], - "tags": ["Users"] + "tags": [ + "Users" + ] } }, "/view/folder": { @@ -7076,7 +7421,9 @@ "api_key": [] } ], - "tags": ["View"] + "tags": [ + "View" + ] } }, "/view/folder/unique-paths": { @@ -7109,7 +7456,9 @@ "api_key": [] } ], - "tags": ["View"] + "tags": [ + "View" + ] } } }, @@ -7157,7 +7506,9 @@ "type": "array" } }, - "required": ["permissions"], + "required": [ + "permissions" + ], "type": "object" }, "APIKeyCreateResponseDto": { @@ -7169,7 +7520,10 @@ "type": "string" } }, - "required": ["apiKey", "secret"], + "required": [ + "apiKey", + "secret" + ], "type": "object" }, "APIKeyResponseDto": { @@ -7195,7 +7549,13 @@ "type": "string" } }, - "required": ["createdAt", "id", "name", "permissions", "updatedAt"], + "required": [ + "createdAt", + "id", + "name", + "permissions", + "updatedAt" + ], "type": "object" }, "APIKeyUpdateDto": { @@ -7204,7 +7564,9 @@ "type": "string" } }, - "required": ["name"], + "required": [ + "name" + ], "type": "object" }, "ActivityCreateDto": { @@ -7224,7 +7586,10 @@ "$ref": "#/components/schemas/ReactionType" } }, - "required": ["albumId", "type"], + "required": [ + "albumId", + "type" + ], "type": "object" }, "ActivityResponseDto": { @@ -7251,7 +7616,13 @@ "$ref": "#/components/schemas/UserResponseDto" } }, - "required": ["assetId", "createdAt", "id", "type", "user"], + "required": [ + "assetId", + "createdAt", + "id", + "type", + "user" + ], "type": "object" }, "ActivityStatisticsResponseDto": { @@ -7260,7 +7631,9 @@ "type": "integer" } }, - "required": ["comments"], + "required": [ + "comments" + ], "type": "object" }, "AddUsersDto": { @@ -7272,7 +7645,9 @@ "type": "array" } }, - "required": ["albumUsers"], + "required": [ + "albumUsers" + ], "type": "object" }, "AdminOnboardingUpdateDto": { @@ -7281,7 +7656,9 @@ "type": "boolean" } }, - "required": ["isOnboarded"], + "required": [ + "isOnboarded" + ], "type": "object" }, "AlbumAssetResponseDto": { @@ -7295,7 +7672,10 @@ "type": "string" } }, - "required": ["albumId", "assetId"], + "required": [ + "albumId", + "assetId" + ], "type": "object" }, "AlbumResponseDto": { @@ -7397,7 +7777,11 @@ "type": "integer" } }, - "required": ["notShared", "owned", "shared"], + "required": [ + "notShared", + "owned", + "shared" + ], "type": "object" }, "AlbumUserAddDto": { @@ -7410,7 +7794,9 @@ "type": "string" } }, - "required": ["userId"], + "required": [ + "userId" + ], "type": "object" }, "AlbumUserCreateDto": { @@ -7423,7 +7809,10 @@ "type": "string" } }, - "required": ["role", "userId"], + "required": [ + "role", + "userId" + ], "type": "object" }, "AlbumUserResponseDto": { @@ -7435,11 +7824,17 @@ "$ref": "#/components/schemas/UserResponseDto" } }, - "required": ["role", "user"], + "required": [ + "role", + "user" + ], "type": "object" }, "AlbumUserRole": { - "enum": ["editor", "viewer"], + "enum": [ + "editor", + "viewer" + ], "type": "string" }, "AllJobStatusResponseDto": { @@ -7518,7 +7913,9 @@ "type": "array" } }, - "required": ["ids"], + "required": [ + "ids" + ], "type": "object" }, "AssetBulkUpdateDto": { @@ -7555,7 +7952,9 @@ "type": "number" } }, - "required": ["ids"], + "required": [ + "ids" + ], "type": "object" }, "AssetBulkUploadCheckDto": { @@ -7567,7 +7966,9 @@ "type": "array" } }, - "required": ["assets"], + "required": [ + "assets" + ], "type": "object" }, "AssetBulkUploadCheckItem": { @@ -7580,7 +7981,10 @@ "type": "string" } }, - "required": ["checksum", "id"], + "required": [ + "checksum", + "id" + ], "type": "object" }, "AssetBulkUploadCheckResponseDto": { @@ -7592,13 +7996,18 @@ "type": "array" } }, - "required": ["results"], + "required": [ + "results" + ], "type": "object" }, "AssetBulkUploadCheckResult": { "properties": { "action": { - "enum": ["accept", "reject"], + "enum": [ + "accept", + "reject" + ], "type": "string" }, "assetId": { @@ -7611,11 +8020,17 @@ "type": "boolean" }, "reason": { - "enum": ["duplicate", "unsupported-format"], + "enum": [ + "duplicate", + "unsupported-format" + ], "type": "string" } }, - "required": ["action", "id"], + "required": [ + "action", + "id" + ], "type": "object" }, "AssetDeltaSyncDto": { @@ -7632,7 +8047,10 @@ "type": "array" } }, - "required": ["updatedAfter", "userIds"], + "required": [ + "updatedAfter", + "userIds" + ], "type": "object" }, "AssetDeltaSyncResponseDto": { @@ -7653,7 +8071,11 @@ "type": "array" } }, - "required": ["deleted", "needsFullSync", "upserted"], + "required": [ + "deleted", + "needsFullSync", + "upserted" + ], "type": "object" }, "AssetFaceResponseDto": { @@ -7713,7 +8135,9 @@ "type": "array" } }, - "required": ["data"], + "required": [ + "data" + ], "type": "object" }, "AssetFaceUpdateItem": { @@ -7727,7 +8151,10 @@ "type": "string" } }, - "required": ["assetId", "personId"], + "required": [ + "assetId", + "personId" + ], "type": "object" }, "AssetFaceWithoutPersonResponseDto": { @@ -7788,7 +8215,10 @@ "type": "string" } }, - "required": ["limit", "updatedUntil"], + "required": [ + "limit", + "updatedUntil" + ], "type": "object" }, "AssetIdsDto": { @@ -7801,7 +8231,9 @@ "type": "array" } }, - "required": ["assetIds"], + "required": [ + "assetIds" + ], "type": "object" }, "AssetIdsResponseDto": { @@ -7810,14 +8242,21 @@ "type": "string" }, "error": { - "enum": ["duplicate", "no_permission", "not_found"], + "enum": [ + "duplicate", + "no_permission", + "not_found" + ], "type": "string" }, "success": { "type": "boolean" } }, - "required": ["assetId", "success"], + "required": [ + "assetId", + "success" + ], "type": "object" }, "AssetJobName": { @@ -7842,7 +8281,10 @@ "$ref": "#/components/schemas/AssetJobName" } }, - "required": ["assetIds", "name"], + "required": [ + "assetIds", + "name" + ], "type": "object" }, "AssetMediaCreateDto": { @@ -7937,19 +8379,32 @@ "$ref": "#/components/schemas/AssetMediaStatus" } }, - "required": ["id", "status"], + "required": [ + "id", + "status" + ], "type": "object" }, "AssetMediaSize": { - "enum": ["preview", "thumbnail"], + "enum": [ + "preview", + "thumbnail" + ], "type": "string" }, "AssetMediaStatus": { - "enum": ["created", "replaced", "duplicate"], + "enum": [ + "created", + "replaced", + "duplicate" + ], "type": "string" }, "AssetOrder": { - "enum": ["asc", "desc"], + "enum": [ + "asc", + "desc" + ], "type": "string" }, "AssetResponseDto": { @@ -8110,7 +8565,11 @@ "type": "string" } }, - "required": ["assetCount", "id", "primaryAssetId"], + "required": [ + "assetCount", + "id", + "primaryAssetId" + ], "type": "object" }, "AssetStatsResponseDto": { @@ -8125,15 +8584,29 @@ "type": "integer" } }, - "required": ["images", "total", "videos"], + "required": [ + "images", + "total", + "videos" + ], "type": "object" }, "AssetTypeEnum": { - "enum": ["IMAGE", "VIDEO", "AUDIO", "OTHER"], + "enum": [ + "IMAGE", + "VIDEO", + "AUDIO", + "OTHER" + ], "type": "string" }, "AudioCodec": { - "enum": ["mp3", "aac", "libopus", "pcm_s16le"], + "enum": [ + "mp3", + "aac", + "libopus", + "pcm_s16le" + ], "type": "string" }, "AuditDeletesResponseDto": { @@ -8148,7 +8621,10 @@ "type": "boolean" } }, - "required": ["ids", "needsFullSync"], + "required": [ + "ids", + "needsFullSync" + ], "type": "object" }, "AvatarResponse": { @@ -8157,7 +8633,9 @@ "$ref": "#/components/schemas/UserAvatarColor" } }, - "required": ["color"], + "required": [ + "color" + ], "type": "object" }, "AvatarUpdate": { @@ -8171,7 +8649,12 @@ "BulkIdResponseDto": { "properties": { "error": { - "enum": ["duplicate", "no_permission", "not_found", "unknown"], + "enum": [ + "duplicate", + "no_permission", + "not_found", + "unknown" + ], "type": "string" }, "id": { @@ -8181,7 +8664,10 @@ "type": "boolean" } }, - "required": ["id", "success"], + "required": [ + "id", + "success" + ], "type": "object" }, "BulkIdsDto": { @@ -8194,7 +8680,9 @@ "type": "array" } }, - "required": ["ids"], + "required": [ + "ids" + ], "type": "object" }, "CLIPConfig": { @@ -8206,11 +8694,18 @@ "type": "string" } }, - "required": ["enabled", "modelName"], + "required": [ + "enabled", + "modelName" + ], "type": "object" }, "CQMode": { - "enum": ["auto", "cqp", "icq"], + "enum": [ + "auto", + "cqp", + "icq" + ], "type": "string" }, "ChangePasswordDto": { @@ -8225,7 +8720,10 @@ "type": "string" } }, - "required": ["newPassword", "password"], + "required": [ + "newPassword", + "password" + ], "type": "object" }, "CheckExistingAssetsDto": { @@ -8240,7 +8738,10 @@ "type": "string" } }, - "required": ["deviceAssetIds", "deviceId"], + "required": [ + "deviceAssetIds", + "deviceId" + ], "type": "object" }, "CheckExistingAssetsResponseDto": { @@ -8252,11 +8753,16 @@ "type": "array" } }, - "required": ["existingIds"], + "required": [ + "existingIds" + ], "type": "object" }, "Colorspace": { - "enum": ["srgb", "p3"], + "enum": [ + "srgb", + "p3" + ], "type": "string" }, "CreateAlbumDto": { @@ -8281,7 +8787,9 @@ "type": "string" } }, - "required": ["albumName"], + "required": [ + "albumName" + ], "type": "object" }, "CreateLibraryDto": { @@ -8306,7 +8814,9 @@ "type": "string" } }, - "required": ["ownerId"], + "required": [ + "ownerId" + ], "type": "object" }, "CreateProfileImageDto": { @@ -8316,7 +8826,9 @@ "type": "string" } }, - "required": ["file"], + "required": [ + "file" + ], "type": "object" }, "CreateProfileImageResponseDto": { @@ -8332,7 +8844,11 @@ "type": "string" } }, - "required": ["profileChangedAt", "profileImagePath", "userId"], + "required": [ + "profileChangedAt", + "profileImagePath", + "userId" + ], "type": "object" }, "DownloadArchiveInfo": { @@ -8347,7 +8863,10 @@ "type": "integer" } }, - "required": ["assetIds", "size"], + "required": [ + "assetIds", + "size" + ], "type": "object" }, "DownloadInfoDto": { @@ -8384,7 +8903,10 @@ "type": "boolean" } }, - "required": ["archiveSize", "includeEmbeddedVideos"], + "required": [ + "archiveSize", + "includeEmbeddedVideos" + ], "type": "object" }, "DownloadResponseDto": { @@ -8399,7 +8921,10 @@ "type": "integer" } }, - "required": ["archives", "totalSize"], + "required": [ + "archives", + "totalSize" + ], "type": "object" }, "DownloadUpdate": { @@ -8426,7 +8951,10 @@ "type": "number" } }, - "required": ["enabled", "maxDistance"], + "required": [ + "enabled", + "maxDistance" + ], "type": "object" }, "DuplicateResponseDto": { @@ -8441,7 +8969,10 @@ "type": "string" } }, - "required": ["assets", "duplicateId"], + "required": [ + "assets", + "duplicateId" + ], "type": "object" }, "EmailNotificationsResponse": { @@ -8456,7 +8987,11 @@ "type": "boolean" } }, - "required": ["albumInvite", "albumUpdate", "enabled"], + "required": [ + "albumInvite", + "albumUpdate", + "enabled" + ], "type": "object" }, "EmailNotificationsUpdate": { @@ -8474,7 +9009,10 @@ "type": "object" }, "EntityType": { - "enum": ["ASSET", "ALBUM"], + "enum": [ + "ASSET", + "ALBUM" + ], "type": "string" }, "ExifResponseDto": { @@ -8602,7 +9140,9 @@ "type": "string" } }, - "required": ["id"], + "required": [ + "id" + ], "type": "object" }, "FacialRecognitionConfig": { @@ -8648,7 +9188,9 @@ "type": "array" } }, - "required": ["filenames"], + "required": [ + "filenames" + ], "type": "object" }, "FileChecksumResponseDto": { @@ -8660,7 +9202,10 @@ "type": "string" } }, - "required": ["checksum", "filename"], + "required": [ + "checksum", + "filename" + ], "type": "object" }, "FileReportDto": { @@ -8678,7 +9223,10 @@ "type": "array" } }, - "required": ["extras", "orphans"], + "required": [ + "extras", + "orphans" + ], "type": "object" }, "FileReportFixDto": { @@ -8690,7 +9238,9 @@ "type": "array" } }, - "required": ["items"], + "required": [ + "items" + ], "type": "object" }, "FileReportItemDto": { @@ -8712,7 +9262,12 @@ "type": "string" } }, - "required": ["entityId", "entityType", "pathType", "pathValue"], + "required": [ + "entityId", + "entityType", + "pathType", + "pathValue" + ], "type": "object" }, "FoldersResponse": { @@ -8726,7 +9281,10 @@ "type": "boolean" } }, - "required": ["enabled", "sidebarWeb"], + "required": [ + "enabled", + "sidebarWeb" + ], "type": "object" }, "FoldersUpdate": { @@ -8741,11 +9299,20 @@ "type": "object" }, "ImageFormat": { - "enum": ["jpeg", "webp"], + "enum": [ + "jpeg", + "webp" + ], "type": "string" }, "JobCommand": { - "enum": ["start", "pause", "resume", "empty", "clear-failed"], + "enum": [ + "start", + "pause", + "resume", + "empty", + "clear-failed" + ], "type": "string" }, "JobCommandDto": { @@ -8757,7 +9324,9 @@ "type": "boolean" } }, - "required": ["command"], + "required": [ + "command" + ], "type": "object" }, "JobCountsDto": { @@ -8797,7 +9366,9 @@ "$ref": "#/components/schemas/ManualJobName" } }, - "required": ["name"], + "required": [ + "name" + ], "type": "object" }, "JobName": { @@ -8826,7 +9397,9 @@ "type": "integer" } }, - "required": ["concurrency"], + "required": [ + "concurrency" + ], "type": "object" }, "JobStatusDto": { @@ -8838,7 +9411,10 @@ "$ref": "#/components/schemas/QueueStatusDto" } }, - "required": ["jobCounts", "queueStatus"], + "required": [ + "jobCounts", + "queueStatus" + ], "type": "object" }, "LibraryResponseDto": { @@ -8914,7 +9490,12 @@ "type": "integer" } }, - "required": ["photos", "total", "usage", "videos"], + "required": [ + "photos", + "total", + "usage", + "videos" + ], "type": "object" }, "LicenseKeyDto": { @@ -8927,7 +9508,10 @@ "type": "string" } }, - "required": ["activationKey", "licenseKey"], + "required": [ + "activationKey", + "licenseKey" + ], "type": "object" }, "LicenseResponseDto": { @@ -8944,11 +9528,22 @@ "type": "string" } }, - "required": ["activatedAt", "activationKey", "licenseKey"], + "required": [ + "activatedAt", + "activationKey", + "licenseKey" + ], "type": "object" }, "LogLevel": { - "enum": ["verbose", "debug", "log", "warn", "error", "fatal"], + "enum": [ + "verbose", + "debug", + "log", + "warn", + "error", + "fatal" + ], "type": "string" }, "LoginCredentialDto": { @@ -8962,7 +9557,10 @@ "type": "string" } }, - "required": ["email", "password"], + "required": [ + "email", + "password" + ], "type": "object" }, "LoginResponseDto": { @@ -9009,11 +9607,18 @@ "type": "boolean" } }, - "required": ["redirectUri", "successful"], + "required": [ + "redirectUri", + "successful" + ], "type": "object" }, "ManualJobName": { - "enum": ["person-cleanup", "tag-cleanup", "user-cleanup"], + "enum": [ + "person-cleanup", + "tag-cleanup", + "user-cleanup" + ], "type": "string" }, "MapMarkerResponseDto": { @@ -9042,7 +9647,14 @@ "type": "string" } }, - "required": ["city", "country", "id", "lat", "lon", "state"], + "required": [ + "city", + "country", + "id", + "lat", + "lon", + "state" + ], "type": "object" }, "MapReverseGeocodeResponseDto": { @@ -9060,7 +9672,11 @@ "type": "string" } }, - "required": ["city", "country", "state"], + "required": [ + "city", + "country", + "state" + ], "type": "object" }, "MemoriesResponse": { @@ -9070,7 +9686,9 @@ "type": "boolean" } }, - "required": ["enabled"], + "required": [ + "enabled" + ], "type": "object" }, "MemoriesUpdate": { @@ -9108,7 +9726,11 @@ "$ref": "#/components/schemas/MemoryType" } }, - "required": ["data", "memoryAt", "type"], + "required": [ + "data", + "memoryAt", + "type" + ], "type": "object" }, "MemoryLaneResponseDto": { @@ -9123,7 +9745,10 @@ "type": "integer" } }, - "required": ["assets", "yearsAgo"], + "required": [ + "assets", + "yearsAgo" + ], "type": "object" }, "MemoryResponseDto": { @@ -9184,7 +9809,9 @@ "type": "object" }, "MemoryType": { - "enum": ["on_this_day"], + "enum": [ + "on_this_day" + ], "type": "string" }, "MemoryUpdateDto": { @@ -9213,7 +9840,9 @@ "type": "array" } }, - "required": ["ids"], + "required": [ + "ids" + ], "type": "object" }, "MetadataSearchDto": { @@ -9374,7 +10003,9 @@ "type": "string" } }, - "required": ["url"], + "required": [ + "url" + ], "type": "object" }, "OAuthCallbackDto": { @@ -9383,7 +10014,9 @@ "type": "string" } }, - "required": ["url"], + "required": [ + "url" + ], "type": "object" }, "OAuthConfigDto": { @@ -9392,7 +10025,9 @@ "type": "string" } }, - "required": ["redirectUri"], + "required": [ + "redirectUri" + ], "type": "object" }, "OnThisDayDto": { @@ -9402,11 +10037,16 @@ "type": "number" } }, - "required": ["year"], + "required": [ + "year" + ], "type": "object" }, "PartnerDirection": { - "enum": ["shared-by", "shared-with"], + "enum": [ + "shared-by", + "shared-with" + ], "type": "string" }, "PartnerResponseDto": { @@ -9445,7 +10085,11 @@ "type": "object" }, "PathEntityType": { - "enum": ["asset", "person", "user"], + "enum": [ + "asset", + "person", + "user" + ], "type": "string" }, "PathType": { @@ -9471,7 +10115,10 @@ "type": "boolean" } }, - "required": ["enabled", "sidebarWeb"], + "required": [ + "enabled", + "sidebarWeb" + ], "type": "object" }, "PeopleResponseDto": { @@ -9493,7 +10140,11 @@ "type": "integer" } }, - "required": ["hidden", "people", "total"], + "required": [ + "hidden", + "people", + "total" + ], "type": "object" }, "PeopleUpdate": { @@ -9516,7 +10167,9 @@ "type": "array" } }, - "required": ["people"], + "required": [ + "people" + ], "type": "object" }, "PeopleUpdateItem": { @@ -9544,7 +10197,9 @@ "type": "string" } }, - "required": ["id"], + "required": [ + "id" + ], "type": "object" }, "Permission": { @@ -9674,7 +10329,13 @@ "type": "string" } }, - "required": ["birthDate", "id", "isHidden", "name", "thumbnailPath"], + "required": [ + "birthDate", + "id", + "isHidden", + "name", + "thumbnailPath" + ], "type": "object" }, "PersonStatisticsResponseDto": { @@ -9683,7 +10344,9 @@ "type": "integer" } }, - "required": ["assets"], + "required": [ + "assets" + ], "type": "object" }, "PersonUpdateDto": { @@ -9768,7 +10431,11 @@ "type": "string" } }, - "required": ["latitude", "longitude", "name"], + "required": [ + "latitude", + "longitude", + "name" + ], "type": "object" }, "PurchaseResponse": { @@ -9780,7 +10447,10 @@ "type": "boolean" } }, - "required": ["hideBuyButtonUntil", "showSupportBadge"], + "required": [ + "hideBuyButtonUntil", + "showSupportBadge" + ], "type": "object" }, "PurchaseUpdate": { @@ -9803,7 +10473,10 @@ "type": "boolean" } }, - "required": ["isActive", "isPaused"], + "required": [ + "isActive", + "isPaused" + ], "type": "object" }, "RandomSearchDto": { @@ -9933,7 +10606,9 @@ "type": "boolean" } }, - "required": ["enabled"], + "required": [ + "enabled" + ], "type": "object" }, "RatingsUpdate": { @@ -9945,11 +10620,17 @@ "type": "object" }, "ReactionLevel": { - "enum": ["album", "asset"], + "enum": [ + "album", + "asset" + ], "type": "string" }, "ReactionType": { - "enum": ["comment", "like"], + "enum": [ + "comment", + "like" + ], "type": "string" }, "ReverseGeocodingStateResponseDto": { @@ -9963,7 +10644,10 @@ "type": "string" } }, - "required": ["lastImportFileName", "lastUpdate"], + "required": [ + "lastImportFileName", + "lastUpdate" + ], "type": "object" }, "SearchAlbumResponseDto": { @@ -9987,7 +10671,12 @@ "type": "integer" } }, - "required": ["count", "facets", "items", "total"], + "required": [ + "count", + "facets", + "items", + "total" + ], "type": "object" }, "SearchAssetResponseDto": { @@ -10015,7 +10704,13 @@ "type": "integer" } }, - "required": ["count", "facets", "items", "nextPage", "total"], + "required": [ + "count", + "facets", + "items", + "nextPage", + "total" + ], "type": "object" }, "SearchExploreItem": { @@ -10027,7 +10722,10 @@ "type": "string" } }, - "required": ["data", "value"], + "required": [ + "data", + "value" + ], "type": "object" }, "SearchExploreResponseDto": { @@ -10042,7 +10740,10 @@ "type": "array" } }, - "required": ["fieldName", "items"], + "required": [ + "fieldName", + "items" + ], "type": "object" }, "SearchFacetCountResponseDto": { @@ -10054,7 +10755,10 @@ "type": "string" } }, - "required": ["count", "value"], + "required": [ + "count", + "value" + ], "type": "object" }, "SearchFacetResponseDto": { @@ -10069,7 +10773,10 @@ "type": "string" } }, - "required": ["counts", "fieldName"], + "required": [ + "counts", + "fieldName" + ], "type": "object" }, "SearchResponseDto": { @@ -10081,11 +10788,20 @@ "$ref": "#/components/schemas/SearchAssetResponseDto" } }, - "required": ["albums", "assets"], + "required": [ + "albums", + "assets" + ], "type": "object" }, "SearchSuggestionType": { - "enum": ["country", "state", "city", "camera-make", "camera-model"], + "enum": [ + "country", + "state", + "city", + "camera-make", + "camera-model" + ], "type": "string" }, "ServerAboutResponseDto": { @@ -10154,7 +10870,11 @@ "type": "string" } }, - "required": ["licensed", "version", "versionUrl"], + "required": [ + "licensed", + "version", + "versionUrl" + ], "type": "object" }, "ServerConfigDto": { @@ -10284,7 +11004,11 @@ "type": "array" } }, - "required": ["image", "sidecar", "video"], + "required": [ + "image", + "sidecar", + "video" + ], "type": "object" }, "ServerPingResponse": { @@ -10295,7 +11019,9 @@ "type": "string" } }, - "required": ["res"], + "required": [ + "res" + ], "type": "object" }, "ServerStatsResponseDto": { @@ -10329,7 +11055,12 @@ "type": "integer" } }, - "required": ["photos", "usage", "usageByUser", "videos"], + "required": [ + "photos", + "usage", + "usageByUser", + "videos" + ], "type": "object" }, "ServerStorageResponseDto": { @@ -10377,7 +11108,9 @@ "type": "string" } }, - "required": ["customCss"], + "required": [ + "customCss" + ], "type": "object" }, "ServerVersionHistoryResponseDto": { @@ -10393,7 +11126,11 @@ "type": "string" } }, - "required": ["createdAt", "id", "version"], + "required": [ + "createdAt", + "id", + "version" + ], "type": "object" }, "ServerVersionResponseDto": { @@ -10408,7 +11145,11 @@ "type": "integer" } }, - "required": ["major", "minor", "patch"], + "required": [ + "major", + "minor", + "patch" + ], "type": "object" }, "SessionResponseDto": { @@ -10482,7 +11223,9 @@ "$ref": "#/components/schemas/SharedLinkType" } }, - "required": ["type"], + "required": [ + "type" + ], "type": "object" }, "SharedLinkEditDto": { @@ -10585,7 +11328,10 @@ "type": "object" }, "SharedLinkType": { - "enum": ["ALBUM", "INDIVIDUAL"], + "enum": [ + "ALBUM", + "INDIVIDUAL" + ], "type": "string" }, "SignUpDto": { @@ -10603,7 +11349,11 @@ "type": "string" } }, - "required": ["email", "name", "password"], + "required": [ + "email", + "name", + "password" + ], "type": "object" }, "SmartInfoResponseDto": { @@ -10744,11 +11494,16 @@ "type": "boolean" } }, - "required": ["query"], + "required": [ + "query" + ], "type": "object" }, "SourceType": { - "enum": ["machine-learning", "exif"], + "enum": [ + "machine-learning", + "exif" + ], "type": "string" }, "StackCreateDto": { @@ -10762,7 +11517,9 @@ "type": "array" } }, - "required": ["assetIds"], + "required": [ + "assetIds" + ], "type": "object" }, "StackResponseDto": { @@ -10780,7 +11537,11 @@ "type": "string" } }, - "required": ["assets", "id", "primaryAssetId"], + "required": [ + "assets", + "id", + "primaryAssetId" + ], "type": "object" }, "StackUpdateDto": { @@ -10840,7 +11601,10 @@ "type": "object" }, "SyncAction": { - "enum": ["upsert", "delete"], + "enum": [ + "upsert", + "delete" + ], "type": "string" }, "SyncCheckpointDto": { @@ -10853,7 +11617,10 @@ "type": "string" } }, - "required": ["id", "timestamp"], + "required": [ + "id", + "timestamp" + ], "type": "object" }, "SyncStreamDto": { @@ -10881,7 +11648,9 @@ "type": "array" } }, - "required": ["types"], + "required": [ + "types" + ], "type": "object" }, "SyncStreamResponseDto": { @@ -10927,7 +11696,11 @@ "$ref": "#/components/schemas/SyncType" } }, - "required": ["action", "data", "type"], + "required": [ + "action", + "data", + "type" + ], "type": "object" }, "SyncType": { @@ -11147,7 +11920,9 @@ "type": "boolean" } }, - "required": ["import"], + "required": [ + "import" + ], "type": "object" }, "SystemConfigGeneratedImageDto": { @@ -11165,7 +11940,11 @@ "type": "integer" } }, - "required": ["format", "quality", "size"], + "required": [ + "format", + "quality", + "size" + ], "type": "object" }, "SystemConfigImageDto": { @@ -11183,7 +11962,12 @@ "$ref": "#/components/schemas/SystemConfigGeneratedImageDto" } }, - "required": ["colorspace", "extractEmbedded", "preview", "thumbnail"], + "required": [ + "colorspace", + "extractEmbedded", + "preview", + "thumbnail" + ], "type": "object" }, "SystemConfigJobDto": { @@ -11246,7 +12030,10 @@ "$ref": "#/components/schemas/SystemConfigLibraryWatchDto" } }, - "required": ["scan", "watch"], + "required": [ + "scan", + "watch" + ], "type": "object" }, "SystemConfigLibraryScanDto": { @@ -11258,7 +12045,10 @@ "type": "boolean" } }, - "required": ["cronExpression", "enabled"], + "required": [ + "cronExpression", + "enabled" + ], "type": "object" }, "SystemConfigLibraryWatchDto": { @@ -11267,7 +12057,9 @@ "type": "boolean" } }, - "required": ["enabled"], + "required": [ + "enabled" + ], "type": "object" }, "SystemConfigLoggingDto": { @@ -11279,7 +12071,10 @@ "$ref": "#/components/schemas/LogLevel" } }, - "required": ["enabled", "level"], + "required": [ + "enabled", + "level" + ], "type": "object" }, "SystemConfigMachineLearningDto": { @@ -11321,7 +12116,11 @@ "type": "string" } }, - "required": ["darkStyle", "enabled", "lightStyle"], + "required": [ + "darkStyle", + "enabled", + "lightStyle" + ], "type": "object" }, "SystemConfigMetadataDto": { @@ -11330,7 +12129,9 @@ "$ref": "#/components/schemas/SystemConfigFacesDto" } }, - "required": ["faces"], + "required": [ + "faces" + ], "type": "object" }, "SystemConfigNewVersionCheckDto": { @@ -11339,7 +12140,9 @@ "type": "boolean" } }, - "required": ["enabled"], + "required": [ + "enabled" + ], "type": "object" }, "SystemConfigNotificationsDto": { @@ -11348,7 +12151,9 @@ "$ref": "#/components/schemas/SystemConfigSmtpDto" } }, - "required": ["smtp"], + "required": [ + "smtp" + ], "type": "object" }, "SystemConfigOAuthDto": { @@ -11425,7 +12230,9 @@ "type": "boolean" } }, - "required": ["enabled"], + "required": [ + "enabled" + ], "type": "object" }, "SystemConfigReverseGeocodingDto": { @@ -11434,7 +12241,9 @@ "type": "boolean" } }, - "required": ["enabled"], + "required": [ + "enabled" + ], "type": "object" }, "SystemConfigServerDto": { @@ -11446,7 +12255,10 @@ "type": "string" } }, - "required": ["externalDomain", "loginPageMessage"], + "required": [ + "externalDomain", + "loginPageMessage" + ], "type": "object" }, "SystemConfigSmtpDto": { @@ -11464,7 +12276,12 @@ "$ref": "#/components/schemas/SystemConfigSmtpTransportDto" } }, - "required": ["enabled", "from", "replyTo", "transport"], + "required": [ + "enabled", + "from", + "replyTo", + "transport" + ], "type": "object" }, "SystemConfigSmtpTransportDto": { @@ -11487,7 +12304,13 @@ "type": "string" } }, - "required": ["host", "ignoreCert", "password", "port", "username"], + "required": [ + "host", + "ignoreCert", + "password", + "port", + "username" + ], "type": "object" }, "SystemConfigStorageTemplateDto": { @@ -11502,7 +12325,11 @@ "type": "string" } }, - "required": ["enabled", "hashVerificationEnabled", "template"], + "required": [ + "enabled", + "hashVerificationEnabled", + "template" + ], "type": "object" }, "SystemConfigTemplateStorageOptionDto": { @@ -11574,7 +12401,9 @@ "type": "string" } }, - "required": ["customCss"], + "required": [ + "customCss" + ], "type": "object" }, "SystemConfigTrashDto": { @@ -11587,7 +12416,10 @@ "type": "boolean" } }, - "required": ["days", "enabled"], + "required": [ + "days", + "enabled" + ], "type": "object" }, "SystemConfigUserDto": { @@ -11597,7 +12429,9 @@ "type": "integer" } }, - "required": ["deleteDelay"], + "required": [ + "deleteDelay" + ], "type": "object" }, "TagBulkAssetsDto": { @@ -11617,7 +12451,10 @@ "type": "array" } }, - "required": ["assetIds", "tagIds"], + "required": [ + "assetIds", + "tagIds" + ], "type": "object" }, "TagBulkAssetsResponseDto": { @@ -11626,7 +12463,9 @@ "type": "integer" } }, - "required": ["count"], + "required": [ + "count" + ], "type": "object" }, "TagCreateDto": { @@ -11643,7 +12482,9 @@ "type": "string" } }, - "required": ["name"], + "required": [ + "name" + ], "type": "object" }, "TagResponseDto": { @@ -11672,7 +12513,13 @@ "type": "string" } }, - "required": ["createdAt", "id", "name", "updatedAt", "value"], + "required": [ + "createdAt", + "id", + "name", + "updatedAt", + "value" + ], "type": "object" }, "TagUpdateDto": { @@ -11693,7 +12540,9 @@ "type": "array" } }, - "required": ["tags"], + "required": [ + "tags" + ], "type": "object" }, "TagsResponse": { @@ -11707,7 +12556,10 @@ "type": "boolean" } }, - "required": ["enabled", "sidebarWeb"], + "required": [ + "enabled", + "sidebarWeb" + ], "type": "object" }, "TagsUpdate": { @@ -11727,7 +12579,9 @@ "type": "string" } }, - "required": ["messageId"], + "required": [ + "messageId" + ], "type": "object" }, "TimeBucketResponseDto": { @@ -11739,23 +12593,46 @@ "type": "string" } }, - "required": ["count", "timeBucket"], + "required": [ + "count", + "timeBucket" + ], "type": "object" }, "TimeBucketSize": { - "enum": ["DAY", "MONTH"], + "enum": [ + "DAY", + "MONTH" + ], "type": "string" }, "ToneMapping": { - "enum": ["hable", "mobius", "reinhard", "disabled"], + "enum": [ + "hable", + "mobius", + "reinhard", + "disabled" + ], "type": "string" }, "TranscodeHWAccel": { - "enum": ["nvenc", "qsv", "vaapi", "rkmpp", "disabled"], + "enum": [ + "nvenc", + "qsv", + "vaapi", + "rkmpp", + "disabled" + ], "type": "string" }, "TranscodePolicy": { - "enum": ["all", "optimal", "bitrate", "required", "disabled"], + "enum": [ + "all", + "optimal", + "bitrate", + "required", + "disabled" + ], "type": "string" }, "TrashResponseDto": { @@ -11764,7 +12641,9 @@ "type": "integer" } }, - "required": ["count"], + "required": [ + "count" + ], "type": "object" }, "UpdateAlbumDto": { @@ -11794,7 +12673,9 @@ "$ref": "#/components/schemas/AlbumUserRole" } }, - "required": ["role"], + "required": [ + "role" + ], "type": "object" }, "UpdateAssetDto": { @@ -11856,7 +12737,9 @@ "type": "boolean" } }, - "required": ["inTimeline"], + "required": [ + "inTimeline" + ], "type": "object" }, "UsageByUserDto": { @@ -11921,7 +12804,11 @@ "type": "string" } }, - "required": ["email", "name", "password"], + "required": [ + "email", + "name", + "password" + ], "type": "object" }, "UserAdminDeleteDto": { @@ -12077,7 +12964,11 @@ "type": "string" } }, - "required": ["activatedAt", "activationKey", "licenseKey"], + "required": [ + "activatedAt", + "activationKey", + "licenseKey" + ], "type": "object" }, "UserPreferencesResponseDto": { @@ -12188,7 +13079,11 @@ "type": "object" }, "UserStatus": { - "enum": ["active", "removing", "deleted"], + "enum": [ + "active", + "removing", + "deleted" + ], "type": "string" }, "UserUpdateMeDto": { @@ -12211,7 +13106,9 @@ "type": "boolean" } }, - "required": ["authStatus"], + "required": [ + "authStatus" + ], "type": "object" }, "ValidateLibraryDto": { @@ -12244,7 +13141,10 @@ "type": "string" } }, - "required": ["importPath", "isValid"], + "required": [ + "importPath", + "isValid" + ], "type": "object" }, "ValidateLibraryResponseDto": { @@ -12259,13 +13159,23 @@ "type": "object" }, "VideoCodec": { - "enum": ["h264", "hevc", "vp9", "av1"], + "enum": [ + "h264", + "hevc", + "vp9", + "av1" + ], "type": "string" }, "VideoContainer": { - "enum": ["mov", "mp4", "ogg", "webm"], + "enum": [ + "mov", + "mp4", + "ogg", + "webm" + ], "type": "string" } } } -} +} \ No newline at end of file From d2eca4fc6348bea995ad51ae38a54b5fce29e5b2 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 24 Oct 2024 18:03:31 -0500 Subject: [PATCH 07/15] parsing response --- mobile/lib/interfaces/sync_api.interface.dart | 2 +- mobile/lib/repositories/sync.repository.dart | 54 ++++---- .../lib/repositories/sync_api.repository.dart | 130 ++++++++++-------- 3 files changed, 100 insertions(+), 86 deletions(-) diff --git a/mobile/lib/interfaces/sync_api.interface.dart b/mobile/lib/interfaces/sync_api.interface.dart index 0baa14cc5c469..281efc142e49f 100644 --- a/mobile/lib/interfaces/sync_api.interface.dart +++ b/mobile/lib/interfaces/sync_api.interface.dart @@ -1,4 +1,4 @@ abstract interface class ISyncApiRepository { - Stream getChanges(); + Stream> getChanges(); Future confirmChages(String changeId); } diff --git a/mobile/lib/repositories/sync.repository.dart b/mobile/lib/repositories/sync.repository.dart index 3a8da6944fd0c..fafe5d2f12906 100644 --- a/mobile/lib/repositories/sync.repository.dart +++ b/mobile/lib/repositories/sync.repository.dart @@ -48,34 +48,34 @@ class SyncRepository extends DatabaseRepository implements ISyncRepository { @override Future incrementalSync() async { _apiRepository.getChanges().listen((event) async { - final type = jsonDecode(event)['type']; - final data = jsonDecode(event)['data']; + // final type = jsonDecode(event)['type']; + // final data = jsonDecode(event)['data']; - if (type == 'album_added') { - final dto = AlbumResponseDto.fromJson(data); - final album = await Album.remote(dto!); - onAlbumAdded?.call(album); - } else if (type == 'album_deleted') { - final dto = AlbumResponseDto.fromJson(data); - final album = await Album.remote(dto!); - onAlbumDeleted?.call(album); - } else if (type == 'album_updated') { - final dto = AlbumResponseDto.fromJson(data); - final album = await Album.remote(dto!); - onAlbumUpdated?.call(album); - } else if (type == 'asset_added') { - final dto = AssetResponseDto.fromJson(data); - final asset = Asset.remote(dto!); - onAssetAdded?.call(asset); - } else if (type == 'asset_deleted') { - final dto = AssetResponseDto.fromJson(data); - final asset = Asset.remote(dto!); - onAssetDeleted?.call(asset); - } else if (type == 'asset_updated') { - final dto = AssetResponseDto.fromJson(data); - final asset = Asset.remote(dto!); - onAssetUpdated?.call(asset); - } + // if (type == 'album_added') { + // final dto = AlbumResponseDto.fromJson(data); + // final album = await Album.remote(dto!); + // onAlbumAdded?.call(album); + // } else if (type == 'album_deleted') { + // final dto = AlbumResponseDto.fromJson(data); + // final album = await Album.remote(dto!); + // onAlbumDeleted?.call(album); + // } else if (type == 'album_updated') { + // final dto = AlbumResponseDto.fromJson(data); + // final album = await Album.remote(dto!); + // onAlbumUpdated?.call(album); + // } else if (type == 'asset_added') { + // final dto = AssetResponseDto.fromJson(data); + // final asset = Asset.remote(dto!); + // onAssetAdded?.call(asset); + // } else if (type == 'asset_deleted') { + // final dto = AssetResponseDto.fromJson(data); + // final asset = Asset.remote(dto!); + // onAssetDeleted?.call(asset); + // } else if (type == 'asset_updated') { + // final dto = AssetResponseDto.fromJson(data); + // final asset = Asset.remote(dto!); + // onAssetUpdated?.call(asset); + // } }); } } diff --git a/mobile/lib/repositories/sync_api.repository.dart b/mobile/lib/repositories/sync_api.repository.dart index b8334e8eb26e2..a5a0cf139fe9a 100644 --- a/mobile/lib/repositories/sync_api.repository.dart +++ b/mobile/lib/repositories/sync_api.repository.dart @@ -1,7 +1,5 @@ import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; import 'package:immich_mobile/entities/album.entity.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/sync_api.interface.dart'; @@ -23,27 +21,37 @@ class SyncApiRepository extends ApiRepository implements ISyncApiRepository { SyncApiRepository(this._api); @override - Stream getChanges() async* { + Stream> getChanges() async* { final serverUrl = Store.get(StoreKey.serverUrl); final accessToken = Store.get(StoreKey.accessToken); - final url = Uri.parse('$serverUrl/sync'); + final url = Uri.parse('$serverUrl/sync/stream'); final client = http.Client(); final request = http.Request('POST', url); - request.headers['x-immich-user-token'] = accessToken; - request.body = jsonEncode({ - 'types': ["asset"], + final headers = { + 'Content-Type': 'application/json', + 'x-immich-user-token': accessToken, + }; + + request.headers.addAll(headers); + request.body = json.encode({ + "types": ["asset"], }); try { final response = await client.send(request); - + String previousChunk = ''; await for (var chunk in response.stream.transform(utf8.decoder)) { - // final data = await compute(_parseSyncReponse, chunk); - final data = _parseSyncReponse(chunk); - print("Data: $data"); - yield chunk; + final isComplete = chunk.endsWith('\n'); + + if (isComplete) { + final jsonString = previousChunk + chunk; + yield await compute(_parseSyncReponse, jsonString); + previousChunk = ''; + } else { + previousChunk += chunk; + } } } catch (e, stack) { print(stack); @@ -61,53 +69,59 @@ class SyncApiRepository extends ApiRepository implements ISyncApiRepository { } Map _parseSyncReponse(String jsonString) { - final type = jsonDecode(jsonString)['type']; - final data = jsonDecode(jsonString)['data']; - final action = jsonDecode(jsonString)['action']; - - switch (type) { - case 'asset': - if (action == 'upsert') { - return {type: AssetResponseDto.fromJson(data)}; - } - - if (action == 'delete') { - return {type: data}; - } - - case 'album': - final dto = AlbumResponseDto.fromJson(data); - if (dto == null) { - return {}; - } - - final album = Album.remote(dto); - return {type: album}; - - case 'albumAsset': //AlbumAssetResponseDto - // final dto = AlbumAssetResponseDto.fromJson(data); - // final album = Album.remote(dto!); - break; - - case 'user': - final dto = UserResponseDto.fromJson(data); - if (dto == null) { - return {}; - } - - final user = User.fromSimpleUserDto(dto); - return {type: user}; - - case 'partner': - final dto = PartnerResponseDto.fromJson(data); - if (dto == null) { - return {}; - } + try { + jsonString = jsonString.trim(); + final type = jsonDecode(jsonString)['type']; + final data = jsonDecode(jsonString)['data']; + final action = jsonDecode(jsonString)['action']; + + switch (type) { + case 'asset': + if (action == 'upsert') { + return {type: AssetResponseDto.fromJson(data)}; + } + + if (action == 'delete') { + return {type: data}; + } + + case 'album': + final dto = AlbumResponseDto.fromJson(data); + if (dto == null) { + return {}; + } + + final album = Album.remote(dto); + return {type: album}; + + case 'albumAsset': //AlbumAssetResponseDto + // final dto = AlbumAssetResponseDto.fromJson(data); + // final album = Album.remote(dto!); + break; + + case 'user': + final dto = UserResponseDto.fromJson(data); + if (dto == null) { + return {}; + } + + final user = User.fromSimpleUserDto(dto); + return {type: user}; + + case 'partner': + final dto = PartnerResponseDto.fromJson(data); + if (dto == null) { + return {}; + } + + final partner = User.fromPartnerDto(dto); + return {type: partner}; + } - final partner = User.fromPartnerDto(dto); - return {type: partner}; + return {"invalid": null}; + } catch (e) { + print("error parsing json $e"); + return {"invalid": null}; } - - return {"invalid": null}; } } From dad88dcd4e78a73c74b6f93c2b304a9ff9d57af4 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 25 Oct 2024 09:44:30 -0500 Subject: [PATCH 08/15] processing batches --- mobile/lib/interfaces/sync.interface.dart | 11 +- mobile/lib/interfaces/sync_api.interface.dart | 6 +- .../models/sync/asset_sync_event.model.dart | 1 + mobile/lib/repositories/sync.repository.dart | 110 ++++++++++++------ .../lib/repositories/sync_api.repository.dart | 71 +++++------ mobile/lib/services/sync.service.dart | 34 +++--- 6 files changed, 127 insertions(+), 106 deletions(-) create mode 100644 mobile/lib/models/sync/asset_sync_event.model.dart diff --git a/mobile/lib/interfaces/sync.interface.dart b/mobile/lib/interfaces/sync.interface.dart index c17007b3db184..069d16967ec10 100644 --- a/mobile/lib/interfaces/sync.interface.dart +++ b/mobile/lib/interfaces/sync.interface.dart @@ -1,16 +1,19 @@ import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/interfaces/database.interface.dart'; +import 'package:openapi/api.dart'; abstract interface class ISyncRepository implements IDatabaseRepository { - void Function(Asset)? onAssetAdded; - void Function(Asset)? onAssetDeleted; - void Function(Asset)? onAssetUpdated; + void Function(List)? onAssetUpserted; + void Function(List)? onAssetDeleted; void Function(Album)? onAlbumAdded; void Function(Album)? onAlbumDeleted; void Function(Album)? onAlbumUpdated; Future fullSync(); - Future incrementalSync(); + Future incrementalSync({ + required List types, + required int batchSize, + }); } diff --git a/mobile/lib/interfaces/sync_api.interface.dart b/mobile/lib/interfaces/sync_api.interface.dart index 281efc142e49f..b87b17ab14be5 100644 --- a/mobile/lib/interfaces/sync_api.interface.dart +++ b/mobile/lib/interfaces/sync_api.interface.dart @@ -1,4 +1,8 @@ +import 'package:openapi/api.dart'; + abstract interface class ISyncApiRepository { - Stream> getChanges(); + Stream> getChanges( + List types, + ); Future confirmChages(String changeId); } diff --git a/mobile/lib/models/sync/asset_sync_event.model.dart b/mobile/lib/models/sync/asset_sync_event.model.dart new file mode 100644 index 0000000000000..8b137891791fe --- /dev/null +++ b/mobile/lib/models/sync/asset_sync_event.model.dart @@ -0,0 +1 @@ + diff --git a/mobile/lib/repositories/sync.repository.dart b/mobile/lib/repositories/sync.repository.dart index fafe5d2f12906..f63aa543fcb04 100644 --- a/mobile/lib/repositories/sync.repository.dart +++ b/mobile/lib/repositories/sync.repository.dart @@ -1,5 +1,3 @@ -import 'dart:convert'; - import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/interfaces/sync.interface.dart'; @@ -27,13 +25,10 @@ class SyncRepository extends DatabaseRepository implements ISyncRepository { void Function(Album)? onAlbumUpdated; @override - void Function(Asset)? onAssetAdded; - - @override - void Function(Asset)? onAssetDeleted; + void Function(List)? onAssetUpserted; @override - void Function(Asset)? onAssetUpdated; + void Function(List)? onAssetDeleted; final SyncApiRepository _apiRepository; @@ -46,36 +41,75 @@ class SyncRepository extends DatabaseRepository implements ISyncRepository { } @override - Future incrementalSync() async { - _apiRepository.getChanges().listen((event) async { - // final type = jsonDecode(event)['type']; - // final data = jsonDecode(event)['data']; - - // if (type == 'album_added') { - // final dto = AlbumResponseDto.fromJson(data); - // final album = await Album.remote(dto!); - // onAlbumAdded?.call(album); - // } else if (type == 'album_deleted') { - // final dto = AlbumResponseDto.fromJson(data); - // final album = await Album.remote(dto!); - // onAlbumDeleted?.call(album); - // } else if (type == 'album_updated') { - // final dto = AlbumResponseDto.fromJson(data); - // final album = await Album.remote(dto!); - // onAlbumUpdated?.call(album); - // } else if (type == 'asset_added') { - // final dto = AssetResponseDto.fromJson(data); - // final asset = Asset.remote(dto!); - // onAssetAdded?.call(asset); - // } else if (type == 'asset_deleted') { - // final dto = AssetResponseDto.fromJson(data); - // final asset = Asset.remote(dto!); - // onAssetDeleted?.call(asset); - // } else if (type == 'asset_updated') { - // final dto = AssetResponseDto.fromJson(data); - // final asset = Asset.remote(dto!); - // onAssetUpdated?.call(asset); - // } - }); + Future incrementalSync({ + required List types, + required int batchSize, + }) async { + List> batch = []; + SyncStreamDtoTypesEnum type = SyncStreamDtoTypesEnum.asset; + + _apiRepository.getChanges(types).listen( + (event) async { + type = event.keys.first; + final data = event.values.first; + + switch (type) { + case SyncStreamDtoTypesEnum.asset: + if (data is Asset) { + batch.add({ + SyncAction.upsert: data, + }); + } + + if (data is String) { + batch.add({ + SyncAction.delete: data, + }); + } + + if (batch.length >= batchSize) { + _processBatch(batch, type); + batch.clear(); + } + break; + + default: + break; + } + }, + onDone: () { + _processBatch(batch, type); + }, + ); + } + + void _processBatch( + List> batch, + SyncStreamDtoTypesEnum type, + ) { + switch (type) { + case SyncStreamDtoTypesEnum.asset: + final upserts = batch + .where((element) => element.keys.first == SyncAction.upsert) + .map((e) => e.values.first as Asset) + .toList(); + + final deletes = batch + .where((element) => element.keys.first == SyncAction.delete) + .map((e) => e.values.first as String) + .toList(); + + if (upserts.isNotEmpty) { + onAssetUpserted?.call(upserts); + } + + if (deletes.isNotEmpty) { + onAssetDeleted?.call(deletes); + } + break; + + default: + break; + } } } diff --git a/mobile/lib/repositories/sync_api.repository.dart b/mobile/lib/repositories/sync_api.repository.dart index a5a0cf139fe9a..4e67c5a4ff0c2 100644 --- a/mobile/lib/repositories/sync_api.repository.dart +++ b/mobile/lib/repositories/sync_api.repository.dart @@ -1,5 +1,6 @@ import 'package:flutter/foundation.dart'; import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/sync_api.interface.dart'; @@ -21,14 +22,15 @@ class SyncApiRepository extends ApiRepository implements ISyncApiRepository { SyncApiRepository(this._api); @override - Stream> getChanges() async* { + Stream> getChanges( + List types, + ) async* { 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, @@ -36,7 +38,7 @@ class SyncApiRepository extends ApiRepository implements ISyncApiRepository { request.headers.addAll(headers); request.body = json.encode({ - "types": ["asset"], + "types": types, }); try { @@ -68,60 +70,39 @@ class SyncApiRepository extends ApiRepository implements ISyncApiRepository { throw UnimplementedError(); } - Map _parseSyncReponse(String jsonString) { + Map _parseSyncReponse(String jsonString) { try { jsonString = jsonString.trim(); - final type = jsonDecode(jsonString)['type']; + final type = + SyncStreamDtoTypesEnum.fromJson(jsonDecode(jsonString)['type'])!; + final action = SyncAction.fromJson(jsonDecode(jsonString)['action']); final data = jsonDecode(jsonString)['data']; - final action = jsonDecode(jsonString)['action']; switch (type) { - case 'asset': - if (action == 'upsert') { - return {type: AssetResponseDto.fromJson(data)}; + case SyncStreamDtoTypesEnum.asset: + if (action == SyncAction.upsert) { + final dto = AssetResponseDto.fromJson(data); + if (dto == null) { + return {}; + } + + final asset = Asset.remote(dto); + return {type: asset}; } - if (action == 'delete') { + // Data is the id of the asset if type is delete + if (action == SyncAction.delete) { return {type: data}; } - case 'album': - final dto = AlbumResponseDto.fromJson(data); - if (dto == null) { - return {}; - } - - final album = Album.remote(dto); - return {type: album}; - - case 'albumAsset': //AlbumAssetResponseDto - // final dto = AlbumAssetResponseDto.fromJson(data); - // final album = Album.remote(dto!); - break; - - case 'user': - final dto = UserResponseDto.fromJson(data); - if (dto == null) { - return {}; - } - - final user = User.fromSimpleUserDto(dto); - return {type: user}; - - case 'partner': - final dto = PartnerResponseDto.fromJson(data); - if (dto == null) { - return {}; - } - - final partner = User.fromPartnerDto(dto); - return {type: partner}; + default: + return {}; } - return {"invalid": null}; - } catch (e) { - print("error parsing json $e"); - return {"invalid": null}; + return {}; + } catch (error) { + debugPrint("[_parseSyncReponse] Error parsing json $error"); + return {}; } } } diff --git a/mobile/lib/services/sync.service.dart b/mobile/lib/services/sync.service.dart index 6c8027363c0d2..8b8c89de233ad 100644 --- a/mobile/lib/services/sync.service.dart +++ b/mobile/lib/services/sync.service.dart @@ -29,6 +29,7 @@ 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:logging/logging.dart'; +import 'package:openapi/api.dart'; final syncServiceProvider = Provider( (ref) => SyncService( @@ -75,39 +76,33 @@ class SyncService { _syncRepository.onAlbumDeleted = _onAlbumDeleted; _syncRepository.onAlbumUpdated = _onAlbumUpdated; - _syncRepository.onAssetAdded = _onAssetAdded; + _syncRepository.onAssetUpserted = onAssetUpserted; _syncRepository.onAssetDeleted = _onAssetDeleted; - _syncRepository.onAssetUpdated = _onAssetUpdated; } - void _onAlbumAdded(Album album) { - // Update record in database - // print("_onAlbumAdded: $album"); - } - - void _onAlbumDeleted(Album album) { + void onAssetUpserted(List assets) { // Update record in database - print("Album deleted: $album"); + print("insert assets into database: ${assets.length}"); } - void _onAlbumUpdated(Album album) { + void _onAssetDeleted(List ids) { // Update record in database - print("Album updated: $album"); + print("remove assets in database: $ids"); } - void _onAssetAdded(Asset asset) { + void _onAlbumAdded(Album album) { // Update record in database - // print("Asset added: $asset"); + // print("_onAlbumAdded: $album"); } - void _onAssetDeleted(Asset asset) { + void _onAlbumDeleted(Album album) { // Update record in database - print("Asset deleted: $asset"); + print("Album deleted: $album"); } - void _onAssetUpdated(Asset asset) { + void _onAlbumUpdated(Album album) { // Update record in database - print("Asset updated: $asset"); + print("Album updated: $album"); } // public methods: @@ -879,7 +874,10 @@ class SyncService { } void incrementalSync() async { - await _syncRepository.incrementalSync(); + await _syncRepository.incrementalSync( + types: [SyncStreamDtoTypesEnum.asset], + batchSize: 50, + ); } void fullSync() async { From dcce094179fb10f287cb0ea59f3551fd262f2b44 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 25 Oct 2024 11:07:43 -0500 Subject: [PATCH 09/15] process json line in batches --- mobile/lib/interfaces/sync.interface.dart | 2 +- mobile/lib/interfaces/sync_api.interface.dart | 4 +- mobile/lib/repositories/sync.repository.dart | 36 +------ .../lib/repositories/sync_api.repository.dart | 96 ++++++++++--------- mobile/lib/services/sync.service.dart | 21 +--- 5 files changed, 60 insertions(+), 99 deletions(-) diff --git a/mobile/lib/interfaces/sync.interface.dart b/mobile/lib/interfaces/sync.interface.dart index 069d16967ec10..6164f3d9876de 100644 --- a/mobile/lib/interfaces/sync.interface.dart +++ b/mobile/lib/interfaces/sync.interface.dart @@ -13,7 +13,7 @@ abstract interface class ISyncRepository implements IDatabaseRepository { Future fullSync(); Future incrementalSync({ - required List types, + required SyncStreamDtoTypesEnum type, required int batchSize, }); } diff --git a/mobile/lib/interfaces/sync_api.interface.dart b/mobile/lib/interfaces/sync_api.interface.dart index b87b17ab14be5..f59568257cdf7 100644 --- a/mobile/lib/interfaces/sync_api.interface.dart +++ b/mobile/lib/interfaces/sync_api.interface.dart @@ -1,8 +1,8 @@ import 'package:openapi/api.dart'; abstract interface class ISyncApiRepository { - Stream> getChanges( - List types, + Stream>> getChanges( + SyncStreamDtoTypesEnum type, ); Future confirmChages(String changeId); } diff --git a/mobile/lib/repositories/sync.repository.dart b/mobile/lib/repositories/sync.repository.dart index f63aa543fcb04..45d1bfdd80286 100644 --- a/mobile/lib/repositories/sync.repository.dart +++ b/mobile/lib/repositories/sync.repository.dart @@ -42,44 +42,16 @@ class SyncRepository extends DatabaseRepository implements ISyncRepository { @override Future incrementalSync({ - required List types, + required SyncStreamDtoTypesEnum type, required int batchSize, }) async { List> batch = []; - SyncStreamDtoTypesEnum type = SyncStreamDtoTypesEnum.asset; - _apiRepository.getChanges(types).listen( + _apiRepository.getChanges(type).listen( (event) async { - type = event.keys.first; - final data = event.values.first; - - switch (type) { - case SyncStreamDtoTypesEnum.asset: - if (data is Asset) { - batch.add({ - SyncAction.upsert: data, - }); - } - - if (data is String) { - batch.add({ - SyncAction.delete: data, - }); - } - - if (batch.length >= batchSize) { - _processBatch(batch, type); - batch.clear(); - } - break; - - default: - break; - } - }, - onDone: () { - _processBatch(batch, type); + print("Received batch of ${event.length} changes"); }, + onDone: () {}, ); } diff --git a/mobile/lib/repositories/sync_api.repository.dart b/mobile/lib/repositories/sync_api.repository.dart index 4e67c5a4ff0c2..d8ebb1069e234 100644 --- a/mobile/lib/repositories/sync_api.repository.dart +++ b/mobile/lib/repositories/sync_api.repository.dart @@ -22,9 +22,10 @@ class SyncApiRepository extends ApiRepository implements ISyncApiRepository { SyncApiRepository(this._api); @override - Stream> getChanges( - List types, + Stream>> getChanges( + SyncStreamDtoTypesEnum type, ) async* { + final batchSize = 1000; final serverUrl = Store.get(StoreKey.serverUrl); final accessToken = Store.get(StoreKey.accessToken); @@ -38,28 +39,31 @@ class SyncApiRepository extends ApiRepository implements ISyncApiRepository { request.headers.addAll(headers); request.body = json.encode({ - "types": types, + "types": [type], }); + String previousChunk = ''; + List lines = []; try { final response = await client.send(request); - String previousChunk = ''; + await for (var chunk in response.stream.transform(utf8.decoder)) { - final isComplete = chunk.endsWith('\n'); - - if (isComplete) { - final jsonString = previousChunk + chunk; - yield await compute(_parseSyncReponse, jsonString); - previousChunk = ''; - } else { - previousChunk += chunk; + 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(); } - } catch (e, stack) { - print(stack); - debugPrint("Error: $e"); + } catch (error, stack) { + debugPrint("[getChanges] Error getChangeStream $error $stack"); } finally { - debugPrint("Closing client"); + yield await compute(_parseSyncReponse, lines); client.close(); } } @@ -70,39 +74,43 @@ class SyncApiRepository extends ApiRepository implements ISyncApiRepository { throw UnimplementedError(); } - Map _parseSyncReponse(String jsonString) { - try { - jsonString = jsonString.trim(); - final type = - SyncStreamDtoTypesEnum.fromJson(jsonDecode(jsonString)['type'])!; - final action = SyncAction.fromJson(jsonDecode(jsonString)['action']); - final data = jsonDecode(jsonString)['data']; - - switch (type) { - case SyncStreamDtoTypesEnum.asset: - if (action == SyncAction.upsert) { - final dto = AssetResponseDto.fromJson(data); - if (dto == null) { - return {}; + List> _parseSyncReponse( + List lines, + ) { + final data = lines.map>((line) { + try { + final type = SyncStreamDtoTypesEnum.fromJson(jsonDecode(line)['type'])!; + final action = SyncAction.fromJson(jsonDecode(line)['action']); + final data = jsonDecode(line)['data']; + + switch (type) { + case SyncStreamDtoTypesEnum.asset: + if (action == SyncAction.upsert) { + final dto = AssetResponseDto.fromJson(data); + if (dto == null) { + return {}; + } + + final asset = Asset.remote(dto); + return {type: asset}; } - final asset = Asset.remote(dto); - return {type: asset}; - } + // Data is the id of the asset if type is delete + if (action == SyncAction.delete) { + return {type: data}; + } - // Data is the id of the asset if type is delete - if (action == SyncAction.delete) { - return {type: data}; - } + default: + return {}; + } - default: - return {}; + return {}; + } catch (error) { + debugPrint("[_parseSyncReponse] Error parsing json $error"); + return {}; } + }); - return {}; - } catch (error) { - debugPrint("[_parseSyncReponse] Error parsing json $error"); - return {}; - } + return data.toList(); } } diff --git a/mobile/lib/services/sync.service.dart b/mobile/lib/services/sync.service.dart index 8b8c89de233ad..dd1f179b54296 100644 --- a/mobile/lib/services/sync.service.dart +++ b/mobile/lib/services/sync.service.dart @@ -72,10 +72,6 @@ class SyncService { this._eTagRepository, this._syncRepository, ) { - _syncRepository.onAlbumAdded = _onAlbumAdded; - _syncRepository.onAlbumDeleted = _onAlbumDeleted; - _syncRepository.onAlbumUpdated = _onAlbumUpdated; - _syncRepository.onAssetUpserted = onAssetUpserted; _syncRepository.onAssetDeleted = _onAssetDeleted; } @@ -90,21 +86,6 @@ class SyncService { print("remove assets in database: $ids"); } - void _onAlbumAdded(Album album) { - // Update record in database - // print("_onAlbumAdded: $album"); - } - - void _onAlbumDeleted(Album album) { - // Update record in database - print("Album deleted: $album"); - } - - void _onAlbumUpdated(Album album) { - // Update record in database - print("Album updated: $album"); - } - // public methods: /// Syncs users from the server to the local database @@ -875,7 +856,7 @@ class SyncService { void incrementalSync() async { await _syncRepository.incrementalSync( - types: [SyncStreamDtoTypesEnum.asset], + type: SyncStreamDtoTypesEnum.asset, batchSize: 50, ); } From 7eea97d13af2f107cd3c38b815d69b4866358b9a Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 25 Oct 2024 14:36:49 -0500 Subject: [PATCH 10/15] upsert assets --- mobile/lib/interfaces/sync.interface.dart | 17 +---- mobile/lib/interfaces/sync_api.interface.dart | 3 +- .../models/sync/asset_sync_event.model.dart | 1 - mobile/lib/models/sync/sync_event.model.dart | 67 +++++++++++++++++ mobile/lib/pages/search/search.page.dart | 2 +- mobile/lib/repositories/sync.repository.dart | 75 +------------------ .../lib/repositories/sync_api.repository.dart | 49 ++++++------ mobile/lib/services/background.service.dart | 3 +- mobile/lib/services/sync.service.dart | 75 +++++++++++++------ .../modules/shared/sync_service_test.dart | 2 + mobile/test/repository.mocks.dart | 3 + 11 files changed, 159 insertions(+), 138 deletions(-) delete mode 100644 mobile/lib/models/sync/asset_sync_event.model.dart create mode 100644 mobile/lib/models/sync/sync_event.model.dart diff --git a/mobile/lib/interfaces/sync.interface.dart b/mobile/lib/interfaces/sync.interface.dart index 6164f3d9876de..b41120f50d52d 100644 --- a/mobile/lib/interfaces/sync.interface.dart +++ b/mobile/lib/interfaces/sync.interface.dart @@ -1,19 +1,4 @@ -import 'package:immich_mobile/entities/album.entity.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/interfaces/database.interface.dart'; import 'package:openapi/api.dart'; -abstract interface class ISyncRepository implements IDatabaseRepository { - void Function(List)? onAssetUpserted; - void Function(List)? onAssetDeleted; - - void Function(Album)? onAlbumAdded; - void Function(Album)? onAlbumDeleted; - void Function(Album)? onAlbumUpdated; - - Future fullSync(); - Future incrementalSync({ - required SyncStreamDtoTypesEnum type, - required int batchSize, - }); -} +abstract interface class ISyncRepository implements IDatabaseRepository {} diff --git a/mobile/lib/interfaces/sync_api.interface.dart b/mobile/lib/interfaces/sync_api.interface.dart index f59568257cdf7..1a800db47a3ed 100644 --- a/mobile/lib/interfaces/sync_api.interface.dart +++ b/mobile/lib/interfaces/sync_api.interface.dart @@ -1,7 +1,8 @@ +import 'package:immich_mobile/models/sync/sync_event.model.dart'; import 'package:openapi/api.dart'; abstract interface class ISyncApiRepository { - Stream>> getChanges( + Stream> getChanges( SyncStreamDtoTypesEnum type, ); Future confirmChages(String changeId); diff --git a/mobile/lib/models/sync/asset_sync_event.model.dart b/mobile/lib/models/sync/asset_sync_event.model.dart deleted file mode 100644 index 8b137891791fe..0000000000000 --- a/mobile/lib/models/sync/asset_sync_event.model.dart +++ /dev/null @@ -1 +0,0 @@ - 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..8a40b906b1671 --- /dev/null +++ b/mobile/lib/models/sync/sync_event.model.dart @@ -0,0 +1,67 @@ +// 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; + SyncEvent({ + required this.type, + required this.action, + required this.data, + }); + + SyncEvent copyWith({ + SyncStreamDtoTypesEnum? type, + SyncAction? action, + dynamic data, + }) { + return SyncEvent( + type: type ?? this.type, + action: action ?? this.action, + data: data ?? this.data, + ); + } + + Map toMap() { + return { + 'type': type, + 'action': action, + 'data': data, + }; + } + + 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, + ); + } + + String toJson() => json.encode(toMap()); + + factory SyncEvent.fromJson(String source) => + SyncEvent.fromMap(json.decode(source) as Map); + + @override + String toString() => 'SyncEvent(type: $type, action: $action, data: $data)'; + + @override + bool operator ==(covariant SyncEvent other) { + if (identical(this, other)) return true; + + return other.type == type && other.action == action && other.data == data; + } + + @override + int get hashCode => type.hashCode ^ action.hashCode ^ data.hashCode; +} diff --git a/mobile/lib/pages/search/search.page.dart b/mobile/lib/pages/search/search.page.dart index 2d724c74fd4b0..6c82364676c66 100644 --- a/mobile/lib/pages/search/search.page.dart +++ b/mobile/lib/pages/search/search.page.dart @@ -722,7 +722,7 @@ class QuickLinkList extends ConsumerWidget { title: 'test'.tr(), icon: Icons.favorite_border_rounded, isBottom: true, - onTap: () => ref.read(syncServiceProvider).incrementalSync(), + onTap: () => ref.read(syncServiceProvider).syncAssets(), ), ], ), diff --git a/mobile/lib/repositories/sync.repository.dart b/mobile/lib/repositories/sync.repository.dart index 45d1bfdd80286..130c56d1c6b29 100644 --- a/mobile/lib/repositories/sync.repository.dart +++ b/mobile/lib/repositories/sync.repository.dart @@ -1,87 +1,14 @@ -import 'package:immich_mobile/entities/album.entity.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; 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:immich_mobile/repositories/sync_api.repository.dart'; -import 'package:openapi/api.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; final syncRepositoryProvider = Provider( (ref) => SyncRepository( ref.watch(dbProvider), - ref.watch(syncApiRepositoryProvider), ), ); class SyncRepository extends DatabaseRepository implements ISyncRepository { - @override - void Function(Album)? onAlbumAdded; - - @override - void Function(Album)? onAlbumDeleted; - - @override - void Function(Album)? onAlbumUpdated; - - @override - void Function(List)? onAssetUpserted; - - @override - void Function(List)? onAssetDeleted; - - final SyncApiRepository _apiRepository; - - SyncRepository(super.db, this._apiRepository); - - @override - Future fullSync() { - // TODO: implement fullSync - throw UnimplementedError(); - } - - @override - Future incrementalSync({ - required SyncStreamDtoTypesEnum type, - required int batchSize, - }) async { - List> batch = []; - - _apiRepository.getChanges(type).listen( - (event) async { - print("Received batch of ${event.length} changes"); - }, - onDone: () {}, - ); - } - - void _processBatch( - List> batch, - SyncStreamDtoTypesEnum type, - ) { - switch (type) { - case SyncStreamDtoTypesEnum.asset: - final upserts = batch - .where((element) => element.keys.first == SyncAction.upsert) - .map((e) => e.values.first as Asset) - .toList(); - - final deletes = batch - .where((element) => element.keys.first == SyncAction.delete) - .map((e) => e.values.first as String) - .toList(); - - if (upserts.isNotEmpty) { - onAssetUpserted?.call(upserts); - } - - if (deletes.isNotEmpty) { - onAssetDeleted?.call(deletes); - } - break; - - default: - break; - } - } + SyncRepository(super.db); } diff --git a/mobile/lib/repositories/sync_api.repository.dart b/mobile/lib/repositories/sync_api.repository.dart index d8ebb1069e234..35760edf32a01 100644 --- a/mobile/lib/repositories/sync_api.repository.dart +++ b/mobile/lib/repositories/sync_api.repository.dart @@ -1,9 +1,8 @@ import 'package:flutter/foundation.dart'; -import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/entities/user.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'; @@ -22,7 +21,7 @@ class SyncApiRepository extends ApiRepository implements ISyncApiRepository { SyncApiRepository(this._api); @override - Stream>> getChanges( + Stream> getChanges( SyncStreamDtoTypesEnum type, ) async* { final batchSize = 1000; @@ -74,43 +73,49 @@ class SyncApiRepository extends ApiRepository implements ISyncApiRepository { throw UnimplementedError(); } - List> _parseSyncReponse( + List _parseSyncReponse( List lines, ) { - final data = lines.map>((line) { + final List data = []; + + for (var line in lines) { try { final type = SyncStreamDtoTypesEnum.fromJson(jsonDecode(line)['type'])!; final action = SyncAction.fromJson(jsonDecode(line)['action']); - final data = jsonDecode(line)['data']; + final dataJson = jsonDecode(line)['data']; switch (type) { case SyncStreamDtoTypesEnum.asset: if (action == SyncAction.upsert) { - final dto = AssetResponseDto.fromJson(data); - if (dto == null) { - return {}; - } - + final dto = AssetResponseDto.fromJson(dataJson)!; final asset = Asset.remote(dto); - return {type: asset}; - } - // Data is the id of the asset if type is delete - if (action == SyncAction.delete) { - return {type: data}; + data.add( + SyncEvent( + type: type, + action: SyncAction.upsert, + data: asset, + ), + ); + } else if (action == SyncAction.delete) { + data.add( + SyncEvent( + type: type, + action: SyncAction.delete, + data: dataJson.toString(), + ), + ); } + break; default: - return {}; + break; } - - return {}; } catch (error) { debugPrint("[_parseSyncReponse] Error parsing json $error"); - return {}; } - }); + } - return data.toList(); + return data; } } diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index f08b85eb9c0e5..8fa52835a6dbb 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -374,7 +374,7 @@ class BackgroundService { ExifInfoRepository exifInfoRepository = ExifInfoRepository(db); ETagRepository eTagRepository = ETagRepository(db); SyncApiRepository syncApiRepository = SyncApiRepository(apiService.syncApi); - SyncRepository syncRepository = SyncRepository(db, syncApiRepository); + SyncRepository syncRepository = SyncRepository(db); AlbumMediaRepository albumMediaRepository = AlbumMediaRepository(); FileMediaRepository fileMediaRepository = FileMediaRepository(); AssetMediaRepository assetMediaRepository = AssetMediaRepository(); @@ -400,6 +400,7 @@ class BackgroundService { 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 dd1f179b54296..119aaabcec665 100644 --- a/mobile/lib/services/sync.service.dart +++ b/mobile/lib/services/sync.service.dart @@ -1,6 +1,7 @@ import 'dart:async'; 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'; @@ -13,6 +14,7 @@ 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'; @@ -21,6 +23,7 @@ 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'; @@ -43,6 +46,7 @@ final syncServiceProvider = Provider( ref.watch(userRepositoryProvider), ref.watch(etagRepositoryProvider), ref.watch(syncRepositoryProvider), + ref.watch(syncApiRepositoryProvider), ), ); @@ -57,6 +61,7 @@ class SyncService { final IUserRepository _userRepository; final IETagRepository _eTagRepository; final ISyncRepository _syncRepository; + final ISyncApiRepository _syncApiRepository; final AsyncMutex _lock = AsyncMutex(); final Logger _log = Logger('SyncService'); @@ -71,19 +76,56 @@ class SyncService { this._userRepository, this._eTagRepository, this._syncRepository, - ) { - _syncRepository.onAssetUpserted = onAssetUpserted; - _syncRepository.onAssetDeleted = _onAssetDeleted; - } + this._syncApiRepository, + ); + + void syncAssets() { + final sw = Stopwatch()..start(); + final batchSize = 200; + int batchCount = 0; + final List toUpsert = []; + final List toDelete = []; + + _syncApiRepository.getChanges(SyncStreamDtoTypesEnum.asset).listen( + (event) async { + for (final e in event) { + batchCount++; + + if (e.action == SyncAction.upsert) { + final asset = e.data as Asset; + toUpsert.add(asset); + } else if (e.action == SyncAction.delete) { + final id = e.data as String; + toDelete.add(id); + } - void onAssetUpserted(List assets) { - // Update record in database - print("insert assets into database: ${assets.length}"); + if (batchCount >= batchSize) { + await _syncAssetsBatch(toUpsert, toDelete); + toUpsert.clear(); + toDelete.clear(); + batchCount = 0; + } + } + + // Process any remaining events + if (toUpsert.isNotEmpty || toDelete.isNotEmpty) { + await _syncAssetsBatch(toUpsert, toDelete); + toUpsert.clear(); + toDelete.clear(); + } + }, + onDone: () { + debugPrint("Syncing assets took ${sw.elapsedMilliseconds}ms"); + }, + ); } - void _onAssetDeleted(List ids) { - // Update record in database - print("remove assets in database: $ids"); + Future _syncAssetsBatch( + List toUpsert, + List toDelete, + ) async { + print("Syncing ${toUpsert.length} assets and deleting ${toDelete.length}"); + await _assetRepository.updateAll(toUpsert); } // public methods: @@ -797,7 +839,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", ); } } @@ -853,17 +895,6 @@ class SyncService { return false; } } - - void incrementalSync() async { - await _syncRepository.incrementalSync( - type: SyncStreamDtoTypesEnum.asset, - batchSize: 50, - ); - } - - void fullSync() async { - await _syncRepository.fullSync(); - } } /// Returns a triple(toAdd, toUpdate, toRemove) diff --git a/mobile/test/modules/shared/sync_service_test.dart b/mobile/test/modules/shared/sync_service_test.dart index 47015dc06e146..0dfe5e46ae1d8 100644 --- a/mobile/test/modules/shared/sync_service_test.dart +++ b/mobile/test/modules/shared/sync_service_test.dart @@ -49,6 +49,7 @@ void main() { final MockExifInfoRepository exifInfoRepository = MockExifInfoRepository(); final MockUserRepository userRepository = MockUserRepository(); final MockSyncRepository syncRepository = MockSyncRepository(); + final MockSyncApiRepository syncApiRepository = MockSyncApiRepository(); final MockETagRepository eTagRepository = MockETagRepository(); final MockAlbumMediaRepository albumMediaRepository = MockAlbumMediaRepository(); @@ -88,6 +89,7 @@ void main() { 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 c9e77202298fd..6066351050035 100644 --- a/mobile/test/repository.mocks.dart +++ b/mobile/test/repository.mocks.dart @@ -8,6 +8,7 @@ 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'; @@ -32,3 +33,5 @@ class MockFileMediaRepository extends Mock implements IFileMediaRepository {} class MockAlbumApiRepository extends Mock implements IAlbumApiRepository {} class MockSyncRepository extends Mock implements ISyncRepository {} + +class MockSyncApiRepository extends Mock implements ISyncApiRepository {} From e095c96fa6e798323d2a494aa80ad68dcbd6e13d Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 23 Oct 2024 17:21:28 -0400 Subject: [PATCH 11/15] feat: better mobile sync --- e2e/src/api/specs/sync.e2e-spec.ts | 83 ++ e2e/src/fixtures.ts | 1 + mobile/openapi/README.md | 10 + mobile/openapi/lib/api.dart | 8 + mobile/openapi/lib/api/sync_api.dart | 89 ++ mobile/openapi/lib/api_client.dart | 16 + mobile/openapi/lib/api_helper.dart | 6 + .../lib/model/album_asset_response_dto.dart | 107 +++ .../lib/model/sync_acknowledge_dto.dart | 329 +++++++ mobile/openapi/lib/model/sync_action.dart | 85 ++ .../lib/model/sync_checkpoint_dto.dart | 107 +++ mobile/openapi/lib/model/sync_stream_dto.dart | 209 +++++ .../lib/model/sync_stream_response_dto.dart | 115 +++ .../model/sync_stream_response_dto_data.dart | 830 ++++++++++++++++++ mobile/openapi/lib/model/sync_type.dart | 121 +++ open-api/immich-openapi-specs.json | 266 ++++++ open-api/typescript-sdk/src/fetch-client.ts | 73 ++ server/src/app.module.ts | 2 +- server/src/controllers/sync.controller.ts | 45 +- server/src/dtos/album-asset.dto.ts | 9 + server/src/dtos/sync.dto.ts | 139 ++- server/src/entities/index.ts | 2 + .../src/entities/session-sync-state.entity.ts | 52 ++ server/src/enum.ts | 22 + server/src/interfaces/sync.interface.ts | 43 + .../src/middleware/global-exception.filter.ts | 12 +- .../1729792220961-AddSessionStateTable.ts | 16 + server/src/repositories/index.ts | 3 + server/src/repositories/sync.repository.ts | 114 +++ server/src/services/base.service.ts | 2 + server/src/services/sync.service.ts | 180 +++- server/src/utils/misc.ts | 2 + .../test/repositories/sync.repository.mock.ts | 13 + server/test/utils.ts | 4 + web/src/routes/+layout.svelte | 28 + 35 files changed, 3127 insertions(+), 16 deletions(-) create mode 100644 e2e/src/api/specs/sync.e2e-spec.ts create mode 100644 mobile/openapi/lib/model/album_asset_response_dto.dart create mode 100644 mobile/openapi/lib/model/sync_acknowledge_dto.dart create mode 100644 mobile/openapi/lib/model/sync_action.dart create mode 100644 mobile/openapi/lib/model/sync_checkpoint_dto.dart create mode 100644 mobile/openapi/lib/model/sync_stream_dto.dart create mode 100644 mobile/openapi/lib/model/sync_stream_response_dto.dart create mode 100644 mobile/openapi/lib/model/sync_stream_response_dto_data.dart create mode 100644 mobile/openapi/lib/model/sync_type.dart create mode 100644 server/src/dtos/album-asset.dto.ts create mode 100644 server/src/entities/session-sync-state.entity.ts create mode 100644 server/src/interfaces/sync.interface.ts create mode 100644 server/src/migrations/1729792220961-AddSessionStateTable.ts create mode 100644 server/src/repositories/sync.repository.ts create mode 100644 server/test/repositories/sync.repository.mock.ts 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/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/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 8f7f372efd266..728438ff5dfa0 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(); } From 7e77f05188d9866ab00fbb5333d5811bc9db3cc1 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 28 Oct 2024 09:25:34 -0500 Subject: [PATCH 12/15] wip: clean up logic --- mobile/lib/services/sync.service.dart | 62 ++++++++++++++++++--------- 1 file changed, 41 insertions(+), 21 deletions(-) diff --git a/mobile/lib/services/sync.service.dart b/mobile/lib/services/sync.service.dart index 119aaabcec665..fe48b4a35c5d8 100644 --- a/mobile/lib/services/sync.service.dart +++ b/mobile/lib/services/sync.service.dart @@ -80,52 +80,72 @@ class SyncService { ); void syncAssets() { - final sw = Stopwatch()..start(); - final batchSize = 200; - int batchCount = 0; + final stopWatch = Stopwatch()..start(); + final batchSize = 500; final List toUpsert = []; final List toDelete = []; _syncApiRepository.getChanges(SyncStreamDtoTypesEnum.asset).listen( (event) async { for (final e in event) { - batchCount++; - if (e.action == SyncAction.upsert) { - final asset = e.data as Asset; - toUpsert.add(asset); - } else if (e.action == SyncAction.delete) { - final id = e.data as String; - toDelete.add(id); + toUpsert.add(e.data as Asset); + } + + if (e.action == SyncAction.delete) { + toDelete.add(e.data as String); } - if (batchCount >= batchSize) { - await _syncAssetsBatch(toUpsert, toDelete); + if (toUpsert.length >= batchSize) { + await _upsertAssets(toUpsert); toUpsert.clear(); + } + + if (toDelete.length >= batchSize) { + await _deleteAssets(toDelete); toDelete.clear(); - batchCount = 0; } } // Process any remaining events - if (toUpsert.isNotEmpty || toDelete.isNotEmpty) { - await _syncAssetsBatch(toUpsert, toDelete); - toUpsert.clear(); - toDelete.clear(); + if (toUpsert.isNotEmpty) { + await _upsertAssets(toUpsert); + } + + if (toDelete.isNotEmpty) { + await _deleteAssets(toDelete); } }, onDone: () { - debugPrint("Syncing assets took ${sw.elapsedMilliseconds}ms"); + debugPrint("Syncing assets took ${stopWatch.elapsedMilliseconds}ms"); }, ); } - Future _syncAssetsBatch( + Future _upsertAssets( List toUpsert, + ) async { + print("upsert ${toUpsert.length}"); + final List all = + await _assetRepository.getAll(ownerId: 4260823638590045786); + print("all ${all.length}"); + // await upsertAssetsWithExif(updated); + + // await _assetRepository.transaction(() async { + // await _assetRepository.updateAll(toUpsert); + // }); + final List inDb = await _assetRepository.getAllByOwnerIdChecksum( + toUpsert.map((a) => a.ownerId).toInt64List(), + toUpsert.map((a) => a.checksum).toList(growable: false), + ); + + print("inDb ${inDb.length}"); + } + + Future _deleteAssets( List toDelete, ) async { - print("Syncing ${toUpsert.length} assets and deleting ${toDelete.length}"); - await _assetRepository.updateAll(toUpsert); + /// TODO: Implement deleteAssets } // public methods: From 4e164d737a8f25d36942dae351af2be39b370a6c Mon Sep 17 00:00:00 2001 From: Alex Tran Date: Mon, 28 Oct 2024 15:00:00 -0500 Subject: [PATCH 13/15] wip: acknowledge changes --- mobile/lib/interfaces/sync_api.interface.dart | 6 +- mobile/lib/models/sync/sync_event.model.dart | 33 ++++++++- .../lib/repositories/sync_api.repository.dart | 52 +++++++++++++- mobile/lib/services/sync.service.dart | 72 ++++++++++++++----- 4 files changed, 137 insertions(+), 26 deletions(-) diff --git a/mobile/lib/interfaces/sync_api.interface.dart b/mobile/lib/interfaces/sync_api.interface.dart index 1a800db47a3ed..ad416a1d5772e 100644 --- a/mobile/lib/interfaces/sync_api.interface.dart +++ b/mobile/lib/interfaces/sync_api.interface.dart @@ -5,5 +5,9 @@ abstract interface class ISyncApiRepository { Stream> getChanges( SyncStreamDtoTypesEnum type, ); - Future confirmChages(String changeId); + 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 index 8a40b906b1671..5c294f95115e6 100644 --- a/mobile/lib/models/sync/sync_event.model.dart +++ b/mobile/lib/models/sync/sync_event.model.dart @@ -13,21 +13,32 @@ class SyncEvent { // 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, ); } @@ -36,6 +47,8 @@ class SyncEvent { 'type': type, 'action': action, 'data': data, + 'id': id, + 'timestamp': timestamp, }; } @@ -44,6 +57,8 @@ class 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, ); } @@ -53,15 +68,27 @@ class SyncEvent { SyncEvent.fromMap(json.decode(source) as Map); @override - String toString() => 'SyncEvent(type: $type, action: $action, data: $data)'; + 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; + return other.type == type && + other.action == action && + other.data == data && + other.id == id && + other.timestamp == timestamp; } @override - int get hashCode => type.hashCode ^ action.hashCode ^ data.hashCode; + int get hashCode { + return type.hashCode ^ + action.hashCode ^ + data.hashCode ^ + id.hashCode ^ + timestamp.hashCode; + } } diff --git a/mobile/lib/repositories/sync_api.repository.dart b/mobile/lib/repositories/sync_api.repository.dart index 35760edf32a01..f7a1af5ec1a1c 100644 --- a/mobile/lib/repositories/sync_api.repository.dart +++ b/mobile/lib/repositories/sync_api.repository.dart @@ -68,9 +68,49 @@ class SyncApiRepository extends ApiRepository implements ISyncApiRepository { } @override - Future confirmChages(String changeId) async { - // TODO: implement confirmChages - throw UnimplementedError(); + 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( @@ -83,6 +123,8 @@ class SyncApiRepository extends ApiRepository implements ISyncApiRepository { 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: @@ -95,6 +137,8 @@ class SyncApiRepository extends ApiRepository implements ISyncApiRepository { type: type, action: SyncAction.upsert, data: asset, + id: id, + timestamp: timestamp, ), ); } else if (action == SyncAction.delete) { @@ -103,6 +147,8 @@ class SyncApiRepository extends ApiRepository implements ISyncApiRepository { type: type, action: SyncAction.delete, data: dataJson.toString(), + id: id, + timestamp: timestamp, ), ); } diff --git a/mobile/lib/services/sync.service.dart b/mobile/lib/services/sync.service.dart index fe48b4a35c5d8..6d4671b71f311 100644 --- a/mobile/lib/services/sync.service.dart +++ b/mobile/lib/services/sync.service.dart @@ -81,13 +81,17 @@ class SyncService { void syncAssets() { final stopWatch = Stopwatch()..start(); - final batchSize = 500; + final batchSize = 1000; final List toUpsert = []; final List toDelete = []; - + String ackTimestamp = ""; + String ackId = ""; _syncApiRepository.getChanges(SyncStreamDtoTypesEnum.asset).listen( (event) async { for (final e in event) { + ackTimestamp = e.timestamp; + ackId = e.id; + if (e.action == SyncAction.upsert) { toUpsert.add(e.data as Asset); } @@ -99,21 +103,25 @@ class SyncService { 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); + await _upsertAssets(toUpsert); // Maybe try/catch + await _confirmAssetsChanges(ackId, ackTimestamp); } if (toDelete.isNotEmpty) { await _deleteAssets(toDelete); + await _confirmAssetsChanges(ackId, ackTimestamp); } }, onDone: () { @@ -122,30 +130,27 @@ class SyncService { ); } + Future _confirmAssetsChanges(String id, String timestamp) async { + await _syncApiRepository.confirmChanges( + SyncStreamDtoTypesEnum.asset, + id, + timestamp, + ); + } + Future _upsertAssets( List toUpsert, ) async { - print("upsert ${toUpsert.length}"); - final List all = - await _assetRepository.getAll(ownerId: 4260823638590045786); - print("all ${all.length}"); - // await upsertAssetsWithExif(updated); - - // await _assetRepository.transaction(() async { - // await _assetRepository.updateAll(toUpsert); - // }); - final List inDb = await _assetRepository.getAllByOwnerIdChecksum( - toUpsert.map((a) => a.ownerId).toInt64List(), - toUpsert.map((a) => a.checksum).toList(growable: false), - ); - - print("inDb ${inDb.length}"); + print("process ${toUpsert.length} assets"); + final (updateAssets, newAssets) = await _getAssetsFromDb(toUpsert); + await upsertAssetsWithExif(newAssets); + await _assetRepository.updateAll(updateAssets); } Future _deleteAssets( List toDelete, ) async { - /// TODO: Implement deleteAssets + await _assetRepository.deleteAllByRemoteId(toDelete); } // public methods: @@ -827,6 +832,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; From 3077b691ee4e7e75cf8617d0784f9162f96ea408 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 29 Oct 2024 13:19:14 -0500 Subject: [PATCH 14/15] awaiting for a stream of event --- mobile/lib/interfaces/sync_api.interface.dart | 5 +- mobile/lib/services/sync.service.dart | 79 ++++++++++--------- 2 files changed, 45 insertions(+), 39 deletions(-) diff --git a/mobile/lib/interfaces/sync_api.interface.dart b/mobile/lib/interfaces/sync_api.interface.dart index ad416a1d5772e..0f11fb52ecdd7 100644 --- a/mobile/lib/interfaces/sync_api.interface.dart +++ b/mobile/lib/interfaces/sync_api.interface.dart @@ -2,9 +2,8 @@ import 'package:immich_mobile/models/sync/sync_event.model.dart'; import 'package:openapi/api.dart'; abstract interface class ISyncApiRepository { - Stream> getChanges( - SyncStreamDtoTypesEnum type, - ); + Stream> getChanges(SyncStreamDtoTypesEnum type); + Future confirmChanges( SyncStreamDtoTypesEnum type, String id, diff --git a/mobile/lib/services/sync.service.dart b/mobile/lib/services/sync.service.dart index 6d4671b71f311..af257f91999fe 100644 --- a/mobile/lib/services/sync.service.dart +++ b/mobile/lib/services/sync.service.dart @@ -1,5 +1,6 @@ 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'; @@ -31,6 +32,7 @@ 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'; @@ -79,55 +81,55 @@ class SyncService { this._syncApiRepository, ); - void syncAssets() { - final stopWatch = Stopwatch()..start(); + Future syncAssets() async { final batchSize = 1000; final List toUpsert = []; final List toDelete = []; String ackTimestamp = ""; String ackId = ""; - _syncApiRepository.getChanges(SyncStreamDtoTypesEnum.asset).listen( - (event) async { - 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); - } + final eventStream = + _syncApiRepository.getChanges(SyncStreamDtoTypesEnum.asset); - if (toUpsert.length >= batchSize) { - await _upsertAssets(toUpsert); - toUpsert.clear(); - await _confirmAssetsChanges(ackId, ackTimestamp); - } + await for (final event in eventStream) { + for (final e in event) { + ackTimestamp = e.timestamp; + ackId = e.id; - if (toDelete.length >= batchSize) { - await _deleteAssets(toDelete); - toDelete.clear(); - await _confirmAssetsChanges(ackId, ackTimestamp); - } + if (e.action == SyncAction.upsert) { + toUpsert.add(e.data as Asset); + } + + if (e.action == SyncAction.delete) { + toDelete.add(e.data as String); } - // Process any remaining events - if (toUpsert.isNotEmpty) { - await _upsertAssets(toUpsert); // Maybe try/catch + if (toUpsert.length >= batchSize) { + await _upsertAssets(toUpsert); + toUpsert.clear(); await _confirmAssetsChanges(ackId, ackTimestamp); } - if (toDelete.isNotEmpty) { + if (toDelete.length >= batchSize) { await _deleteAssets(toDelete); + toDelete.clear(); await _confirmAssetsChanges(ackId, ackTimestamp); } - }, - onDone: () { - debugPrint("Syncing assets took ${stopWatch.elapsedMilliseconds}ms"); - }, - ); + } + + // 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); + } + } } Future _confirmAssetsChanges(String id, String timestamp) async { @@ -141,10 +143,15 @@ class SyncService { Future _upsertAssets( List toUpsert, ) async { - print("process ${toUpsert.length} assets"); final (updateAssets, newAssets) = await _getAssetsFromDb(toUpsert); - await upsertAssetsWithExif(newAssets); - await _assetRepository.updateAll(updateAssets); + + if (updateAssets.isNotEmpty) { + await _assetRepository.updateAll(updateAssets); + } + + if (newAssets.isNotEmpty) { + await upsertAssetsWithExif(newAssets); + } } Future _deleteAssets( From b3febbb09475230bb2277135466ecaadab2a4c41 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 29 Oct 2024 14:26:41 -0500 Subject: [PATCH 15/15] catch stream error --- .../lib/repositories/sync_api.repository.dart | 2 - mobile/lib/services/sync.service.dart | 72 ++++++++++--------- 2 files changed, 38 insertions(+), 36 deletions(-) diff --git a/mobile/lib/repositories/sync_api.repository.dart b/mobile/lib/repositories/sync_api.repository.dart index f7a1af5ec1a1c..a71f080b5f411 100644 --- a/mobile/lib/repositories/sync_api.repository.dart +++ b/mobile/lib/repositories/sync_api.repository.dart @@ -59,8 +59,6 @@ class SyncApiRepository extends ApiRepository implements ISyncApiRepository { yield await compute(_parseSyncReponse, lines); lines.clear(); } - } catch (error, stack) { - debugPrint("[getChanges] Error getChangeStream $error $stack"); } finally { yield await compute(_parseSyncReponse, lines); client.close(); diff --git a/mobile/lib/services/sync.service.dart b/mobile/lib/services/sync.service.dart index af257f91999fe..8fe3ba9a473f4 100644 --- a/mobile/lib/services/sync.service.dart +++ b/mobile/lib/services/sync.service.dart @@ -82,53 +82,57 @@ class SyncService { ); Future syncAssets() async { - 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); - } + 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 (e.action == SyncAction.delete) { - toDelete.add(e.data as String); + if (toDelete.length >= batchSize) { + await _deleteAssets(toDelete); + toDelete.clear(); + await _confirmAssetsChanges(ackId, ackTimestamp); + } } - if (toUpsert.length >= batchSize) { + // Process any remaining events + if (toUpsert.isNotEmpty) { await _upsertAssets(toUpsert); toUpsert.clear(); await _confirmAssetsChanges(ackId, ackTimestamp); } - if (toDelete.length >= batchSize) { + if (toDelete.isNotEmpty) { 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); } }