Adapt web client to consume new server response format

This commit is contained in:
Min Idzelis 2025-04-29 13:45:40 +00:00
parent 077703adcc
commit bc5d4b45a6
17 changed files with 367 additions and 60 deletions

View File

@ -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)

View File

@ -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';

View File

@ -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':

View File

@ -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();
}

View File

@ -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<TimelineAssetDescriptionDto> description;
List<TimeBucketAssetResponseDtoDurationInner> duration;
List<String> 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<String, dynamic> toJson() {
final json = <String, dynamic>{};
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<String, dynamic>();
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<String>().toList(growable: false)
@ -200,6 +207,7 @@ class TimeBucketAssetResponseDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'description',
'duration',
'id',
'isArchived',

View File

@ -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<String, dynamic> toJson() {
final json = <String, dynamic>{};
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<String, dynamic>();
return TimelineAssetDescriptionDto(
city: mapValueOfType<String>(json, r'city'),
country: mapValueOfType<String>(json, r'country'),
);
}
return null;
}
static List<TimelineAssetDescriptionDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <TimelineAssetDescriptionDto>[];
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<String, TimelineAssetDescriptionDto> mapFromJson(dynamic json) {
final map = <String, TimelineAssetDescriptionDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // 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<String, List<TimelineAssetDescriptionDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<TimelineAssetDescriptionDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
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 = <String>{
'city',
'country',
};
}

View File

@ -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": {

View File

@ -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[];

View File

@ -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 {

View File

@ -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')

View File

@ -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 {

View File

@ -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[];
};

View File

@ -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<string, AssetResponseDto[]> = {
'2024-03-01T00:00:00.000Z': assetFactory
const bucketAssets: Record<string, TimelineAsset[]> = {
'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<string, TimeBucketResponseDto> = 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<string, AssetResponseDto[]> = {
'2024-01-03T00:00:00.000Z': assetFactory
const bucketAssets: Record<string, TimelineAsset[]> = {
'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<string, TimeBucketResponseDto> = 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<string, AssetResponseDto[]> = {
'2024-03-01T00:00:00.000Z': assetFactory
const bucketAssets: Record<string, TimelineAsset[]> = {
'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<string, TimeBucketResponseDto> = 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 });
});

View File

@ -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);
}
}

View File

@ -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;

View File

@ -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 =>

View File

@ -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<AssetResponseDto>({
@ -42,9 +48,51 @@ export const timelineAssetFactory = Sync.makeFactory<TimelineAsset>({
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;
};