diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 4f9b062ba6..6a3186c4ae 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -145,8 +145,15 @@ Class | Method | HTTP request | Description *MemoriesApi* | [**removeMemoryAssets**](doc//MemoriesApi.md#removememoryassets) | **DELETE** /memories/{id}/assets | *MemoriesApi* | [**searchMemories**](doc//MemoriesApi.md#searchmemories) | **GET** /memories | *MemoriesApi* | [**updateMemory**](doc//MemoriesApi.md#updatememory) | **PUT** /memories/{id} | -*NotificationsAdminApi* | [**getNotificationTemplateAdmin**](doc//NotificationsAdminApi.md#getnotificationtemplateadmin) | **POST** /notifications/admin/templates/{name} | -*NotificationsAdminApi* | [**sendTestEmailAdmin**](doc//NotificationsAdminApi.md#sendtestemailadmin) | **POST** /notifications/admin/test-email | +*NotificationsApi* | [**deleteNotification**](doc//NotificationsApi.md#deletenotification) | **DELETE** /notifications/{id} | +*NotificationsApi* | [**deleteNotifications**](doc//NotificationsApi.md#deletenotifications) | **DELETE** /notifications | +*NotificationsApi* | [**getNotification**](doc//NotificationsApi.md#getnotification) | **GET** /notifications/{id} | +*NotificationsApi* | [**getNotifications**](doc//NotificationsApi.md#getnotifications) | **GET** /notifications | +*NotificationsApi* | [**updateNotification**](doc//NotificationsApi.md#updatenotification) | **PUT** /notifications/{id} | +*NotificationsApi* | [**updateNotifications**](doc//NotificationsApi.md#updatenotifications) | **PUT** /notifications | +*NotificationsAdminApi* | [**createNotification**](doc//NotificationsAdminApi.md#createnotification) | **POST** /admin/notifications | +*NotificationsAdminApi* | [**getNotificationTemplateAdmin**](doc//NotificationsAdminApi.md#getnotificationtemplateadmin) | **POST** /admin/notifications/templates/{name} | +*NotificationsAdminApi* | [**sendTestEmailAdmin**](doc//NotificationsAdminApi.md#sendtestemailadmin) | **POST** /admin/notifications/test-email | *OAuthApi* | [**finishOAuth**](doc//OAuthApi.md#finishoauth) | **POST** /oauth/callback | *OAuthApi* | [**linkOAuthAccount**](doc//OAuthApi.md#linkoauthaccount) | **POST** /oauth/link | *OAuthApi* | [**redirectOAuthToMobile**](doc//OAuthApi.md#redirectoauthtomobile) | **GET** /oauth/mobile-redirect | @@ -300,7 +307,6 @@ Class | Method | HTTP request | Description - [AssetStatsResponseDto](doc//AssetStatsResponseDto.md) - [AssetTypeEnum](doc//AssetTypeEnum.md) - [AudioCodec](doc//AudioCodec.md) - - [AvatarResponse](doc//AvatarResponse.md) - [AvatarUpdate](doc//AvatarUpdate.md) - [BulkIdResponseDto](doc//BulkIdResponseDto.md) - [BulkIdsDto](doc//BulkIdsDto.md) @@ -361,6 +367,13 @@ Class | Method | HTTP request | Description - [MemoryUpdateDto](doc//MemoryUpdateDto.md) - [MergePersonDto](doc//MergePersonDto.md) - [MetadataSearchDto](doc//MetadataSearchDto.md) + - [NotificationCreateDto](doc//NotificationCreateDto.md) + - [NotificationDeleteAllDto](doc//NotificationDeleteAllDto.md) + - [NotificationDto](doc//NotificationDto.md) + - [NotificationLevel](doc//NotificationLevel.md) + - [NotificationType](doc//NotificationType.md) + - [NotificationUpdateAllDto](doc//NotificationUpdateAllDto.md) + - [NotificationUpdateDto](doc//NotificationUpdateDto.md) - [OAuthAuthorizeResponseDto](doc//OAuthAuthorizeResponseDto.md) - [OAuthCallbackDto](doc//OAuthCallbackDto.md) - [OAuthConfigDto](doc//OAuthConfigDto.md) @@ -475,8 +488,12 @@ Class | Method | HTTP request | Description - [TemplateDto](doc//TemplateDto.md) - [TemplateResponseDto](doc//TemplateResponseDto.md) - [TestEmailResponseDto](doc//TestEmailResponseDto.md) + - [TimeBucketAssetResponseDto](doc//TimeBucketAssetResponseDto.md) + - [TimeBucketAssetResponseDtoDurationInner](doc//TimeBucketAssetResponseDtoDurationInner.md) - [TimeBucketResponseDto](doc//TimeBucketResponseDto.md) - - [TimeBucketSize](doc//TimeBucketSize.md) + - [TimeBucketsResponseDto](doc//TimeBucketsResponseDto.md) + - [TimelineAssetDescriptionDto](doc//TimelineAssetDescriptionDto.md) + - [TimelineStackResponseDto](doc//TimelineStackResponseDto.md) - [ToneMapping](doc//ToneMapping.md) - [TranscodeHWAccel](doc//TranscodeHWAccel.md) - [TranscodePolicy](doc//TranscodePolicy.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index ff5a95bbbc..474214f48a 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -44,6 +44,7 @@ part 'api/jobs_api.dart'; part 'api/libraries_api.dart'; part 'api/map_api.dart'; part 'api/memories_api.dart'; +part 'api/notifications_api.dart'; part 'api/notifications_admin_api.dart'; part 'api/o_auth_api.dart'; part 'api/partners_api.dart'; @@ -107,7 +108,6 @@ part 'model/asset_stack_response_dto.dart'; part 'model/asset_stats_response_dto.dart'; part 'model/asset_type_enum.dart'; part 'model/audio_codec.dart'; -part 'model/avatar_response.dart'; part 'model/avatar_update.dart'; part 'model/bulk_id_response_dto.dart'; part 'model/bulk_ids_dto.dart'; @@ -168,6 +168,13 @@ part 'model/memory_type.dart'; part 'model/memory_update_dto.dart'; part 'model/merge_person_dto.dart'; part 'model/metadata_search_dto.dart'; +part 'model/notification_create_dto.dart'; +part 'model/notification_delete_all_dto.dart'; +part 'model/notification_dto.dart'; +part 'model/notification_level.dart'; +part 'model/notification_type.dart'; +part 'model/notification_update_all_dto.dart'; +part 'model/notification_update_dto.dart'; part 'model/o_auth_authorize_response_dto.dart'; part 'model/o_auth_callback_dto.dart'; part 'model/o_auth_config_dto.dart'; @@ -282,8 +289,12 @@ part 'model/tags_update.dart'; part 'model/template_dto.dart'; part 'model/template_response_dto.dart'; part 'model/test_email_response_dto.dart'; +part 'model/time_bucket_asset_response_dto.dart'; +part 'model/time_bucket_asset_response_dto_duration_inner.dart'; part 'model/time_bucket_response_dto.dart'; -part 'model/time_bucket_size.dart'; +part 'model/time_buckets_response_dto.dart'; +part 'model/timeline_asset_description_dto.dart'; +part 'model/timeline_stack_response_dto.dart'; part 'model/tone_mapping.dart'; part 'model/transcode_hw_accel.dart'; part 'model/transcode_policy.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 5759217f41..d86b9e2aee 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -270,8 +270,6 @@ class ApiClient { return AssetTypeEnumTypeTransformer().decode(value); case 'AudioCodec': return AudioCodecTypeTransformer().decode(value); - case 'AvatarResponse': - return AvatarResponse.fromJson(value); case 'AvatarUpdate': return AvatarUpdate.fromJson(value); case 'BulkIdResponseDto': @@ -392,6 +390,20 @@ class ApiClient { return MergePersonDto.fromJson(value); case 'MetadataSearchDto': return MetadataSearchDto.fromJson(value); + case 'NotificationCreateDto': + return NotificationCreateDto.fromJson(value); + case 'NotificationDeleteAllDto': + return NotificationDeleteAllDto.fromJson(value); + case 'NotificationDto': + return NotificationDto.fromJson(value); + case 'NotificationLevel': + return NotificationLevelTypeTransformer().decode(value); + case 'NotificationType': + return NotificationTypeTypeTransformer().decode(value); + case 'NotificationUpdateAllDto': + return NotificationUpdateAllDto.fromJson(value); + case 'NotificationUpdateDto': + return NotificationUpdateDto.fromJson(value); case 'OAuthAuthorizeResponseDto': return OAuthAuthorizeResponseDto.fromJson(value); case 'OAuthCallbackDto': @@ -620,10 +632,18 @@ class ApiClient { return TemplateResponseDto.fromJson(value); case 'TestEmailResponseDto': return TestEmailResponseDto.fromJson(value); + case 'TimeBucketAssetResponseDto': + return TimeBucketAssetResponseDto.fromJson(value); + case 'TimeBucketAssetResponseDtoDurationInner': + return TimeBucketAssetResponseDtoDurationInner.fromJson(value); case 'TimeBucketResponseDto': return TimeBucketResponseDto.fromJson(value); - case 'TimeBucketSize': - return TimeBucketSizeTypeTransformer().decode(value); + case 'TimeBucketsResponseDto': + return TimeBucketsResponseDto.fromJson(value); + case 'TimelineAssetDescriptionDto': + return TimelineAssetDescriptionDto.fromJson(value); + case 'TimelineStackResponseDto': + return TimelineStackResponseDto.fromJson(value); case 'ToneMapping': return ToneMappingTypeTransformer().decode(value); case 'TranscodeHWAccel': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 1ebf8314ad..e1d73e9a80 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -100,6 +100,12 @@ String parameterToString(dynamic value) { if (value is MemoryType) { return MemoryTypeTypeTransformer().encode(value).toString(); } + if (value is NotificationLevel) { + return NotificationLevelTypeTransformer().encode(value).toString(); + } + if (value is NotificationType) { + return NotificationTypeTypeTransformer().encode(value).toString(); + } if (value is PartnerDirection) { return PartnerDirectionTypeTransformer().encode(value).toString(); } @@ -133,9 +139,6 @@ String parameterToString(dynamic value) { if (value is SyncRequestType) { return SyncRequestTypeTypeTransformer().encode(value).toString(); } - if (value is TimeBucketSize) { - return TimeBucketSizeTypeTransformer().encode(value).toString(); - } if (value is ToneMapping) { return ToneMappingTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/time_bucket_asset_response_dto.dart b/mobile/openapi/lib/model/time_bucket_asset_response_dto.dart index 7a0c03a886..14c5b0549b 100644 --- a/mobile/openapi/lib/model/time_bucket_asset_response_dto.dart +++ b/mobile/openapi/lib/model/time_bucket_asset_response_dto.dart @@ -13,6 +13,7 @@ part of openapi.api; class TimeBucketAssetResponseDto { /// Returns a new [TimeBucketAssetResponseDto] instance. TimeBucketAssetResponseDto({ + this.description = const [], this.duration = const [], this.id = const [], this.isArchived = const [], @@ -29,6 +30,8 @@ class TimeBucketAssetResponseDto { this.thumbhash = const [], }); + List description; + List duration; List id; @@ -59,6 +62,7 @@ class TimeBucketAssetResponseDto { @override bool operator ==(Object other) => identical(this, other) || other is TimeBucketAssetResponseDto && + _deepEquality.equals(other.description, description) && _deepEquality.equals(other.duration, duration) && _deepEquality.equals(other.id, id) && _deepEquality.equals(other.isArchived, isArchived) && @@ -77,6 +81,7 @@ class TimeBucketAssetResponseDto { @override int get hashCode => // ignore: unnecessary_parenthesis + (description.hashCode) + (duration.hashCode) + (id.hashCode) + (isArchived.hashCode) + @@ -93,10 +98,11 @@ class TimeBucketAssetResponseDto { (thumbhash.hashCode); @override - String toString() => 'TimeBucketAssetResponseDto[duration=$duration, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isImage=$isImage, isTrashed=$isTrashed, isVideo=$isVideo, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, ownerId=$ownerId, projectionType=$projectionType, ratio=$ratio, stack=$stack, thumbhash=$thumbhash]'; + String toString() => 'TimeBucketAssetResponseDto[description=$description, duration=$duration, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isImage=$isImage, isTrashed=$isTrashed, isVideo=$isVideo, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, ownerId=$ownerId, projectionType=$projectionType, ratio=$ratio, stack=$stack, thumbhash=$thumbhash]'; Map toJson() { final json = {}; + json[r'description'] = this.description; json[r'duration'] = this.duration; json[r'id'] = this.id; json[r'isArchived'] = this.isArchived; @@ -123,6 +129,7 @@ class TimeBucketAssetResponseDto { final json = value.cast(); return TimeBucketAssetResponseDto( + description: TimelineAssetDescriptionDto.listFromJson(json[r'description']), duration: TimeBucketAssetResponseDtoDurationInner.listFromJson(json[r'duration']), id: json[r'id'] is Iterable ? (json[r'id'] as Iterable).cast().toList(growable: false) @@ -200,6 +207,7 @@ class TimeBucketAssetResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { + 'description', 'duration', 'id', 'isArchived', diff --git a/mobile/openapi/lib/model/timeline_asset_description_dto.dart b/mobile/openapi/lib/model/timeline_asset_description_dto.dart new file mode 100644 index 0000000000..1bfb87fc92 --- /dev/null +++ b/mobile/openapi/lib/model/timeline_asset_description_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 TimelineAssetDescriptionDto { + /// Returns a new [TimelineAssetDescriptionDto] instance. + TimelineAssetDescriptionDto({ + required this.city, + required this.country, + }); + + String? city; + + String? country; + + @override + bool operator ==(Object other) => identical(this, other) || other is TimelineAssetDescriptionDto && + other.city == city && + other.country == country; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (city == null ? 0 : city!.hashCode) + + (country == null ? 0 : country!.hashCode); + + @override + String toString() => 'TimelineAssetDescriptionDto[city=$city, country=$country]'; + + Map toJson() { + final json = {}; + if (this.city != null) { + json[r'city'] = this.city; + } else { + // json[r'city'] = null; + } + if (this.country != null) { + json[r'country'] = this.country; + } else { + // json[r'country'] = null; + } + return json; + } + + /// Returns a new [TimelineAssetDescriptionDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static TimelineAssetDescriptionDto? fromJson(dynamic value) { + upgradeDto(value, "TimelineAssetDescriptionDto"); + if (value is Map) { + final json = value.cast(); + + return TimelineAssetDescriptionDto( + city: mapValueOfType(json, r'city'), + country: mapValueOfType(json, r'country'), + ); + } + 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 = TimelineAssetDescriptionDto.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 = TimelineAssetDescriptionDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of TimelineAssetDescriptionDto-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] = TimelineAssetDescriptionDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'city', + 'country', + }; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index d0c69de8cb..60f8116bbd 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -13847,6 +13847,13 @@ }, "TimeBucketAssetResponseDto": { "properties": { + "description": { + "default": [], + "items": { + "$ref": "#/components/schemas/TimelineAssetDescriptionDto" + }, + "type": "array" + }, "duration": { "default": [], "items": { @@ -13976,6 +13983,7 @@ } }, "required": [ + "description", "duration", "id", "isArchived", @@ -14023,6 +14031,23 @@ ], "type": "object" }, + "TimelineAssetDescriptionDto": { + "properties": { + "city": { + "nullable": true, + "type": "string" + }, + "country": { + "nullable": true, + "type": "string" + } + }, + "required": [ + "city", + "country" + ], + "type": "object" + }, "TimelineStackResponseDto": { "properties": { "assetCount": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index f0587e65bb..0077e290ec 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1407,12 +1407,17 @@ export type TagBulkAssetsResponseDto = { export type TagUpdateDto = { color?: string | null; }; +export type TimelineAssetDescriptionDto = { + city: string | null; + country: string | null; +}; export type TimelineStackResponseDto = { assetCount: number; id: string; primaryAssetId: string; }; export type TimeBucketAssetResponseDto = { + description: TimelineAssetDescriptionDto[]; duration: (string | number)[]; id: string[]; isArchived: number[]; diff --git a/server/src/dtos/time-bucket.dto.ts b/server/src/dtos/time-bucket.dto.ts index 99bec06fb3..c5a7d95a5c 100644 --- a/server/src/dtos/time-bucket.dto.ts +++ b/server/src/dtos/time-bucket.dto.ts @@ -2,7 +2,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsEnum, IsInt, IsString, Min } from 'class-validator'; import { AssetOrder } from 'src/enum'; -import { TimeBucketAssets, TimelineStack } from 'src/services/timeline.service.types'; +import { AssetDescription, TimeBucketAssets, TimelineStack } from 'src/services/timeline.service.types'; import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; export class TimeBucketDto { @@ -64,6 +64,13 @@ export class TimelineStackResponseDto implements TimelineStack { assetCount!: number; } +export class TimelineAssetDescriptionDto implements AssetDescription { + @ApiProperty() + city!: string | null; + @ApiProperty() + country!: string | null; +} + export class TimeBucketAssetResponseDto implements TimeBucketAssets { @ApiProperty({ type: [String] }) id: string[] = []; @@ -154,6 +161,9 @@ export class TimeBucketAssetResponseDto implements TimeBucketAssets { }, }) livePhotoVideoId: (string | number)[] = []; + + @ApiProperty() + description: TimelineAssetDescriptionDto[] = []; } export class TimeBucketsResponseDto { diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index e6224eb349..a7e0ac5f86 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -701,7 +701,14 @@ export class AssetRepository { 'livePhotoVideoId', ]) .leftJoin('exif', 'assets.id', 'exif.assetId') - .select(['exif.exifImageHeight as height', 'exifImageWidth as width', 'exif.orientation', 'exif.projectionType']) + .select([ + 'exif.exifImageHeight as height', + 'exifImageWidth as width', + 'exif.orientation', + 'exif.projectionType', + 'exif.city as city', + 'exif.country as country', + ]) .$if(!!options.albumId, (qb) => qb .innerJoin('albums_assets_assets', 'albums_assets_assets.assetsId', 'assets.id') diff --git a/server/src/services/timeline.service.ts b/server/src/services/timeline.service.ts index 90e535df8a..8bb698d459 100644 --- a/server/src/services/timeline.service.ts +++ b/server/src/services/timeline.service.ts @@ -58,6 +58,7 @@ export class TimelineService extends BaseService { duration: [], projectionType: [], livePhotoVideoId: [], + description: [], }; for (const item of items) { let width = item.width!; @@ -82,6 +83,10 @@ export class TimelineService extends BaseService { bucketAssets.livePhotoVideoId.push(item.livePhotoVideoId || 0); bucketAssets.isImage.push(item.type === AssetType.IMAGE ? 1 : 0); bucketAssets.isVideo.push(item.type === AssetType.VIDEO ? 1 : 0); + bucketAssets.description.push({ + city: item.city, + country: item.country, + }); } return { diff --git a/server/src/services/timeline.service.types.ts b/server/src/services/timeline.service.types.ts index e9a60fed37..0ef344f21c 100644 --- a/server/src/services/timeline.service.types.ts +++ b/server/src/services/timeline.service.types.ts @@ -4,6 +4,11 @@ export type TimelineStack = { assetCount: number; }; +export type AssetDescription = { + city: string | null; + country: string | null; +}; + export type TimeBucketAssets = { id: string[]; ownerId: string[]; @@ -19,4 +24,5 @@ export type TimeBucketAssets = { duration: (string | number)[]; projectionType: (string | number)[]; livePhotoVideoId: (string | number)[]; + description: AssetDescription[]; }; diff --git a/web/src/lib/stores/assets-store.spec.ts b/web/src/lib/stores/assets-store.spec.ts index 12ca5d1e43..d5546a78bb 100644 --- a/web/src/lib/stores/assets-store.spec.ts +++ b/web/src/lib/stores/assets-store.spec.ts @@ -1,8 +1,8 @@ import { sdkMock } from '$lib/__mocks__/sdk.mock'; import { AbortError } from '$lib/utils'; -import { TimeBucketSize, type AssetResponseDto } from '@immich/sdk'; -import { assetFactory, timelineAssetFactory } from '@test-data/factories/asset-factory'; -import { AssetStore } from './assets-store.svelte'; +import { type AssetResponseDto, type TimeBucketResponseDto } from '@immich/sdk'; +import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory'; +import { AssetStore, type TimelineAsset } from './assets-store.svelte'; describe('AssetStore', () => { beforeEach(() => { @@ -11,18 +11,22 @@ describe('AssetStore', () => { describe('init', () => { let assetStore: AssetStore; - const bucketAssets: Record = { - '2024-03-01T00:00:00.000Z': assetFactory + const bucketAssets: Record = { + '2024-03-01T00:00:00.000Z': timelineAssetFactory .buildList(1) .map((asset) => ({ ...asset, localDateTime: '2024-03-01T00:00:00.000Z' })), - '2024-02-01T00:00:00.000Z': assetFactory + '2024-02-01T00:00:00.000Z': timelineAssetFactory .buildList(100) .map((asset) => ({ ...asset, localDateTime: '2024-02-01T00:00:00.000Z' })), - '2024-01-01T00:00:00.000Z': assetFactory + '2024-01-01T00:00:00.000Z': timelineAssetFactory .buildList(3) .map((asset) => ({ ...asset, localDateTime: '2024-01-01T00:00:00.000Z' })), }; + const bucketAssetsResponse: Record = Object.fromEntries( + Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]), + ); + beforeEach(async () => { assetStore = new AssetStore(); sdkMock.getTimeBuckets.mockResolvedValue([ @@ -30,13 +34,14 @@ describe('AssetStore', () => { { count: 100, timeBucket: '2024-02-01T00:00:00.000Z' }, { count: 3, timeBucket: '2024-01-01T00:00:00.000Z' }, ]); - sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssets[timeBucket])); + + sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket])); await assetStore.updateViewport({ width: 1588, height: 1000 }); }); it('should load buckets in viewport', () => { expect(sdkMock.getTimeBuckets).toBeCalledTimes(1); - expect(sdkMock.getTimeBuckets).toBeCalledWith({ size: TimeBucketSize.Month }); + expect(sdkMock.getTimeBucket).toHaveBeenCalledTimes(2); }); @@ -48,29 +53,31 @@ describe('AssetStore', () => { expect(plainBuckets).toEqual( expect.arrayContaining([ - expect.objectContaining({ bucketDate: '2024-03-01T00:00:00.000Z', bucketHeight: 304 }), - expect.objectContaining({ bucketDate: '2024-02-01T00:00:00.000Z', bucketHeight: 4515.333_333_333_333 }), + expect.objectContaining({ bucketDate: '2024-03-01T00:00:00.000Z', bucketHeight: 186.5 }), + expect.objectContaining({ bucketDate: '2024-02-01T00:00:00.000Z', bucketHeight: 12_017 }), expect.objectContaining({ bucketDate: '2024-01-01T00:00:00.000Z', bucketHeight: 286 }), ]), ); }); it('calculates timeline height', () => { - expect(assetStore.timelineHeight).toBe(5105.333_333_333_333); + expect(assetStore.timelineHeight).toBe(12_489.5); }); }); describe('loadBucket', () => { let assetStore: AssetStore; - const bucketAssets: Record = { - '2024-01-03T00:00:00.000Z': assetFactory + const bucketAssets: Record = { + '2024-01-03T00:00:00.000Z': timelineAssetFactory .buildList(1) .map((asset) => ({ ...asset, localDateTime: '2024-03-01T00:00:00.000Z' })), - '2024-01-01T00:00:00.000Z': assetFactory + '2024-01-01T00:00:00.000Z': timelineAssetFactory .buildList(3) .map((asset) => ({ ...asset, localDateTime: '2024-01-01T00:00:00.000Z' })), }; - + const bucketAssetsResponse: Record = Object.fromEntries( + Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]), + ); beforeEach(async () => { assetStore = new AssetStore(); sdkMock.getTimeBuckets.mockResolvedValue([ @@ -82,7 +89,7 @@ describe('AssetStore', () => { if (signal?.aborted) { throw new AbortError(); } - return bucketAssets[timeBucket]; + return bucketAssetsResponse[timeBucket]; }); await assetStore.updateViewport({ width: 1588, height: 0 }); }); @@ -296,7 +303,9 @@ describe('AssetStore', () => { }); it('removes asset from bucket', () => { - const [assetOne, assetTwo] = timelineAssetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' }); + const [assetOne, assetTwo] = timelineAssetFactory.buildList(2, { + localDateTime: '2024-01-20T12:00:00.000Z', + }); assetStore.addAssets([assetOne, assetTwo]); assetStore.removeAssets([assetOne.id]); @@ -342,17 +351,20 @@ describe('AssetStore', () => { describe('getPreviousAsset', () => { let assetStore: AssetStore; - const bucketAssets: Record = { - '2024-03-01T00:00:00.000Z': assetFactory + const bucketAssets: Record = { + '2024-03-01T00:00:00.000Z': timelineAssetFactory .buildList(1) .map((asset) => ({ ...asset, localDateTime: '2024-03-01T00:00:00.000Z' })), - '2024-02-01T00:00:00.000Z': assetFactory + '2024-02-01T00:00:00.000Z': timelineAssetFactory .buildList(6) .map((asset) => ({ ...asset, localDateTime: '2024-02-01T00:00:00.000Z' })), - '2024-01-01T00:00:00.000Z': assetFactory + '2024-01-01T00:00:00.000Z': timelineAssetFactory .buildList(3) .map((asset) => ({ ...asset, localDateTime: '2024-01-01T00:00:00.000Z' })), }; + const bucketAssetsResponse: Record = Object.fromEntries( + Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]), + ); beforeEach(async () => { assetStore = new AssetStore(); @@ -361,8 +373,7 @@ describe('AssetStore', () => { { count: 6, timeBucket: '2024-02-01T00:00:00.000Z' }, { count: 3, timeBucket: '2024-01-01T00:00:00.000Z' }, ]); - sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssets[timeBucket])); - + sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket])); await assetStore.updateViewport({ width: 1588, height: 1000 }); }); diff --git a/web/src/lib/stores/assets-store.svelte.ts b/web/src/lib/stores/assets-store.svelte.ts index 14bb3de37f..0a783b4dfc 100644 --- a/web/src/lib/stores/assets-store.svelte.ts +++ b/web/src/lib/stores/assets-store.svelte.ts @@ -14,9 +14,8 @@ import { getAssetInfo, getTimeBucket, getTimeBuckets, - TimeBucketSize, - type AssetResponseDto, type AssetStackResponseDto, + type TimeBucketResponseDto, } from '@immich/sdk'; import { clamp, debounce, isEqual, throttle } from 'lodash-es'; import { DateTime } from 'luxon'; @@ -84,7 +83,7 @@ export type TimelineAsset = { duration: string | null; projectionType: string | null; livePhotoVideoId: string | null; - text: { + description: { city: string | null; country: string | null; people: string[]; @@ -418,11 +417,34 @@ export class AssetBucket { }; } + #decodeString(stringOrNumber: string | number) { + return typeof stringOrNumber === 'number' ? null : (stringOrNumber as string); + } + // note - if the assets are not part of this bucket, they will not be added - addAssets(bucketResponse: AssetResponseDto[]) { + addAssets(bucketResponse: TimeBucketResponseDto) { const addContext = new AddContext(); - for (const asset of bucketResponse) { - const timelineAsset = toTimelineAsset(asset); + for (let i = 0; i < bucketResponse.bucketAssets.id.length; i++) { + const timelineAsset: TimelineAsset = { + description: { + ...bucketResponse.bucketAssets.description[i], + people: [], + }, + duration: this.#decodeString(bucketResponse.bucketAssets.duration[i]), + id: bucketResponse.bucketAssets.id[i], + isArchived: !!bucketResponse.bucketAssets.isArchived[i], + isFavorite: !!bucketResponse.bucketAssets.isFavorite[i], + isImage: !!bucketResponse.bucketAssets.isImage[i], + isTrashed: !!bucketResponse.bucketAssets.isTrashed[i], + isVideo: !!bucketResponse.bucketAssets.isVideo[i], + livePhotoVideoId: this.#decodeString(bucketResponse.bucketAssets.livePhotoVideoId[i]), + localDateTime: bucketResponse.bucketAssets.localDateTime[i], + ownerId: bucketResponse.bucketAssets.ownerId[i], + projectionType: this.#decodeString(bucketResponse.bucketAssets.projectionType[i]), + ratio: bucketResponse.bucketAssets.ratio[i], + stack: bucketResponse.bucketAssets.stack[i], + thumbhash: this.#decodeString(bucketResponse.bucketAssets.thumbhash[i]), + }; this.addTimelineAsset(timelineAsset, addContext); } @@ -878,7 +900,6 @@ export class AssetStore { async #initialiazeTimeBuckets() { const timebuckets = await getTimeBuckets({ ...this.#options, - size: TimeBucketSize.Month, key: authManager.key, }); @@ -1086,7 +1107,7 @@ export class AssetStore { { ...this.#options, timeBucket: bucketDate, - size: TimeBucketSize.Month, + key: authManager.key, }, { signal }, @@ -1097,12 +1118,11 @@ export class AssetStore { { albumId: this.#options.timelineAlbumId, timeBucket: bucketDate, - size: TimeBucketSize.Month, key: authManager.key, }, { signal }, ); - for (const { id } of albumAssets) { + for (const id of albumAssets.bucketAssets.id) { this.albumAssets.add(id); } } diff --git a/web/src/lib/utils/thumbnail-util.ts b/web/src/lib/utils/thumbnail-util.ts index 95119b6a09..b37918cad2 100644 --- a/web/src/lib/utils/thumbnail-util.ts +++ b/web/src/lib/utils/thumbnail-util.ts @@ -38,15 +38,10 @@ export function getThumbnailSize(assetCount: number, viewWidth: number): number return 300; } -export const getAltTextForTimelineAsset = (_: TimelineAsset) => { - // TODO: implement this in a performant way - return ''; -}; - export const getAltText = derived(t, ($t) => { return (asset: TimelineAsset) => { const date = fromLocalDateTime(asset.localDateTime).toLocaleString({ dateStyle: 'long' }, { locale: get(locale) }); - const { city, country, people: names } = asset.text; + const { city, country, people: names } = asset.description; const hasPlace = city && country; const peopleCount = names.length; diff --git a/web/src/lib/utils/timeline-util.ts b/web/src/lib/utils/timeline-util.ts index a2d230642b..36b52c388e 100644 --- a/web/src/lib/utils/timeline-util.ts +++ b/web/src/lib/utils/timeline-util.ts @@ -71,7 +71,8 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): const city = assetResponse.exifInfo?.city; const country = assetResponse.exifInfo?.country; const people = assetResponse.people?.map((person) => person.name) || []; - const text = { + + const description = { city: city || null, country: country || null, people, @@ -91,7 +92,7 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): duration: assetResponse.duration || null, projectionType: assetResponse.exifInfo?.projectionType || null, livePhotoVideoId: assetResponse.livePhotoVideoId || null, - text, + description, }; }; export const isTimelineAsset = (arg: AssetResponseDto | TimelineAsset): arg is TimelineAsset => diff --git a/web/src/test-data/factories/asset-factory.ts b/web/src/test-data/factories/asset-factory.ts index 42d312663b..c2906568b1 100644 --- a/web/src/test-data/factories/asset-factory.ts +++ b/web/src/test-data/factories/asset-factory.ts @@ -1,6 +1,12 @@ import type { TimelineAsset } from '$lib/stores/assets-store.svelte'; import { faker } from '@faker-js/faker'; -import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk'; +import { + AssetTypeEnum, + type AssetResponseDto, + type TimeBucketAssetResponseDto, + type TimeBucketResponseDto, + type TimelineStackResponseDto, +} from '@immich/sdk'; import { Sync } from 'factory.ts'; export const assetFactory = Sync.makeFactory({ @@ -42,9 +48,51 @@ export const timelineAssetFactory = Sync.makeFactory({ stack: null, projectionType: null, livePhotoVideoId: Sync.each(() => faker.string.uuid()), - text: Sync.each(() => ({ + description: Sync.each(() => ({ city: faker.location.city(), country: faker.location.country(), people: [faker.person.fullName()], })), }); + +export const toResponseDto = (...timelineAsset: TimelineAsset[]) => { + const bucketAssets: TimeBucketAssetResponseDto = { + description: [], + duration: [], + id: [], + isArchived: [], + isFavorite: [], + isImage: [], + isTrashed: [], + isVideo: [], + livePhotoVideoId: [], + localDateTime: [], + ownerId: [], + projectionType: [], + ratio: [], + stack: [], + thumbhash: [], + }; + for (const asset of timelineAsset) { + bucketAssets.description.push(asset.description); + bucketAssets.duration.push(asset.duration || 0); + bucketAssets.id.push(asset.id); + bucketAssets.isArchived.push(asset.isArchived ? 1 : 0); + bucketAssets.isFavorite.push(asset.isFavorite ? 1 : 0); + bucketAssets.isImage.push(asset.isImage ? 1 : 0); + bucketAssets.isTrashed.push(asset.isTrashed ? 1 : 0); + bucketAssets.isVideo.push(asset.isVideo ? 1 : 0); + bucketAssets.livePhotoVideoId.push(asset.livePhotoVideoId || 0); + bucketAssets.localDateTime.push(asset.localDateTime); + bucketAssets.ownerId.push(asset.ownerId); + bucketAssets.projectionType.push(asset.projectionType || 0); + bucketAssets.ratio.push(asset.ratio); + bucketAssets.stack.push(asset.stack as TimelineStackResponseDto); + bucketAssets.thumbhash.push(asset.thumbhash || 0); + } + const response: TimeBucketResponseDto = { + bucketAssets, + hasNextPage: false, + }; + return response; +};