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

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