feat(server): lighter buckets (#17831)

* feat(web): lighter timeline buckets

* GalleryViewer

* weird ssr

* Remove generics from AssetInteraction

* ensure keys on getAssetInfo, alt-text

* empty - trigger ci

* re-add alt-text

* test fix

* update tests

* tests

* missing import

* feat(server): lighter buckets

* fix: flappy e2e test

* lint

* revert settings

* unneeded cast

* fix after merge

* Adapt web client to consume new server response format

* test

* missing import

* lint

* Use nulls, make-sql

* openapi battle

* date->string

* tests

* tests

* lint/tests

* lint

* test

* push aggregation to query

* openapi

* stack as tuple

* openapi

* update references to description

* update alt text tests

* update sql

* update sql

* update timeline tests

* linting, fix expected response

* string tuple

* fix spec

* fix

* silly generator

* rename patch

* minimize sorting

* review

* lint

* lint

* sql

* test

* avoid abbreviations

* review comment - type safety in test

* merge conflicts

* lint

* lint/abbreviations

* remove unncessary code

* review comments

* sql

* re-add package-lock

* use booleans, fix visibility in openapi spec, less cursed controller

* update sql

* no need to use sql template

* array access actually doesn't seem to matter

* remove redundant code

* re-add sql decorator

* unused type

* remove null assertions

* bad merge

* Fix test

* shave

* extra clean shave

* use decorator for content type

* redundant types

* redundant comment

* update comment

* unnecessary res

---------

Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
Min Idzelis 2025-05-19 17:40:48 -04:00 committed by GitHub
parent 59f666b115
commit e7edbcdf04
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 817 additions and 372 deletions

View file

@ -1,4 +1,4 @@
import { AssetMediaResponseDto, AssetVisibility, LoginResponseDto, SharedLinkType, TimeBucketSize } from '@immich/sdk'; import { AssetMediaResponseDto, AssetVisibility, LoginResponseDto, SharedLinkType } from '@immich/sdk';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { createUserDto } from 'src/fixtures'; import { createUserDto } from 'src/fixtures';
import { errorDto } from 'src/responses'; import { errorDto } from 'src/responses';
@ -52,7 +52,7 @@ describe('/timeline', () => {
describe('GET /timeline/buckets', () => { describe('GET /timeline/buckets', () => {
it('should require authentication', async () => { it('should require authentication', async () => {
const { status, body } = await request(app).get('/timeline/buckets').query({ size: TimeBucketSize.Month }); const { status, body } = await request(app).get('/timeline/buckets');
expect(status).toBe(401); expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized); expect(body).toEqual(errorDto.unauthorized);
}); });
@ -60,8 +60,7 @@ describe('/timeline', () => {
it('should get time buckets by month', async () => { it('should get time buckets by month', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/timeline/buckets') .get('/timeline/buckets')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`) .set('Authorization', `Bearer ${timeBucketUser.accessToken}`);
.query({ size: TimeBucketSize.Month });
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toEqual( expect(body).toEqual(
@ -78,33 +77,17 @@ describe('/timeline', () => {
assetIds: userAssets.map(({ id }) => id), assetIds: userAssets.map(({ id }) => id),
}); });
const { status, body } = await request(app) const { status, body } = await request(app).get('/timeline/buckets').query({ key: sharedLink.key });
.get('/timeline/buckets')
.query({ key: sharedLink.key, size: TimeBucketSize.Month });
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.noPermission); expect(body).toEqual(errorDto.noPermission);
}); });
it('should get time buckets by day', async () => {
const { status, body } = await request(app)
.get('/timeline/buckets')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ size: TimeBucketSize.Day });
expect(status).toBe(200);
expect(body).toEqual([
{ count: 2, timeBucket: '1970-02-11T00:00:00.000Z' },
{ count: 1, timeBucket: '1970-02-10T00:00:00.000Z' },
{ count: 1, timeBucket: '1970-01-01T00:00:00.000Z' },
]);
});
it('should return error if time bucket is requested with partners asset and archived', async () => { it('should return error if time bucket is requested with partners asset and archived', async () => {
const req1 = await request(app) const req1 = await request(app)
.get('/timeline/buckets') .get('/timeline/buckets')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`) .set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ size: TimeBucketSize.Month, withPartners: true, visibility: AssetVisibility.Archive }); .query({ withPartners: true, visibility: AssetVisibility.Archive });
expect(req1.status).toBe(400); expect(req1.status).toBe(400);
expect(req1.body).toEqual(errorDto.badRequest()); expect(req1.body).toEqual(errorDto.badRequest());
@ -112,7 +95,7 @@ describe('/timeline', () => {
const req2 = await request(app) const req2 = await request(app)
.get('/timeline/buckets') .get('/timeline/buckets')
.set('Authorization', `Bearer ${user.accessToken}`) .set('Authorization', `Bearer ${user.accessToken}`)
.query({ size: TimeBucketSize.Month, withPartners: true, visibility: undefined }); .query({ withPartners: true, visibility: undefined });
expect(req2.status).toBe(400); expect(req2.status).toBe(400);
expect(req2.body).toEqual(errorDto.badRequest()); expect(req2.body).toEqual(errorDto.badRequest());
@ -122,7 +105,7 @@ describe('/timeline', () => {
const req1 = await request(app) const req1 = await request(app)
.get('/timeline/buckets') .get('/timeline/buckets')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`) .set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ size: TimeBucketSize.Month, withPartners: true, isFavorite: true }); .query({ withPartners: true, isFavorite: true });
expect(req1.status).toBe(400); expect(req1.status).toBe(400);
expect(req1.body).toEqual(errorDto.badRequest()); expect(req1.body).toEqual(errorDto.badRequest());
@ -130,7 +113,7 @@ describe('/timeline', () => {
const req2 = await request(app) const req2 = await request(app)
.get('/timeline/buckets') .get('/timeline/buckets')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`) .set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ size: TimeBucketSize.Month, withPartners: true, isFavorite: false }); .query({ withPartners: true, isFavorite: false });
expect(req2.status).toBe(400); expect(req2.status).toBe(400);
expect(req2.body).toEqual(errorDto.badRequest()); expect(req2.body).toEqual(errorDto.badRequest());
@ -140,7 +123,7 @@ describe('/timeline', () => {
const req = await request(app) const req = await request(app)
.get('/timeline/buckets') .get('/timeline/buckets')
.set('Authorization', `Bearer ${user.accessToken}`) .set('Authorization', `Bearer ${user.accessToken}`)
.query({ size: TimeBucketSize.Month, withPartners: true, isTrashed: true }); .query({ withPartners: true, isTrashed: true });
expect(req.status).toBe(400); expect(req.status).toBe(400);
expect(req.body).toEqual(errorDto.badRequest()); expect(req.body).toEqual(errorDto.badRequest());
@ -150,7 +133,6 @@ describe('/timeline', () => {
describe('GET /timeline/bucket', () => { describe('GET /timeline/bucket', () => {
it('should require authentication', async () => { it('should require authentication', async () => {
const { status, body } = await request(app).get('/timeline/bucket').query({ const { status, body } = await request(app).get('/timeline/bucket').query({
size: TimeBucketSize.Month,
timeBucket: '1900-01-01', timeBucket: '1900-01-01',
}); });
@ -161,11 +143,27 @@ describe('/timeline', () => {
it('should handle 5 digit years', async () => { it('should handle 5 digit years', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/timeline/bucket') .get('/timeline/bucket')
.query({ size: TimeBucketSize.Month, timeBucket: '012345-01-01' }) .query({ timeBucket: '012345-01-01' })
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`); .set('Authorization', `Bearer ${timeBucketUser.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toEqual([]); expect(body).toEqual({
city: [],
country: [],
duration: [],
id: [],
visibility: [],
isFavorite: [],
isImage: [],
isTrashed: [],
livePhotoVideoId: [],
localDateTime: [],
ownerId: [],
projectionType: [],
ratio: [],
status: [],
thumbhash: [],
});
}); });
// TODO enable date string validation while still accepting 5 digit years // TODO enable date string validation while still accepting 5 digit years
@ -173,7 +171,7 @@ describe('/timeline', () => {
// const { status, body } = await request(app) // const { status, body } = await request(app)
// .get('/timeline/bucket') // .get('/timeline/bucket')
// .set('Authorization', `Bearer ${user.accessToken}`) // .set('Authorization', `Bearer ${user.accessToken}`)
// .query({ size: TimeBucketSize.Month, timeBucket: 'foo' }); // .query({ timeBucket: 'foo' });
// expect(status).toBe(400); // expect(status).toBe(400);
// expect(body).toEqual(errorDto.badRequest); // expect(body).toEqual(errorDto.badRequest);
@ -183,10 +181,26 @@ describe('/timeline', () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/timeline/bucket') .get('/timeline/bucket')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`) .set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ size: TimeBucketSize.Month, timeBucket: '1970-02-10' }); .query({ timeBucket: '1970-02-10' });
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toEqual([]); expect(body).toEqual({
city: [],
country: [],
duration: [],
id: [],
visibility: [],
isFavorite: [],
isImage: [],
isTrashed: [],
livePhotoVideoId: [],
localDateTime: [],
ownerId: [],
projectionType: [],
ratio: [],
status: [],
thumbhash: [],
});
}); });
}); });
}); });

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
OPENAPI_GENERATOR_VERSION=v7.8.0 OPENAPI_GENERATOR_VERSION=v7.12.0
# usage: ./bin/generate-open-api.sh # usage: ./bin/generate-open-api.sh
@ -8,6 +8,7 @@ function dart {
cd ./templates/mobile/serialization/native cd ./templates/mobile/serialization/native
wget -O native_class.mustache https://raw.githubusercontent.com/OpenAPITools/openapi-generator/$OPENAPI_GENERATOR_VERSION/modules/openapi-generator/src/main/resources/dart2/serialization/native/native_class.mustache wget -O native_class.mustache https://raw.githubusercontent.com/OpenAPITools/openapi-generator/$OPENAPI_GENERATOR_VERSION/modules/openapi-generator/src/main/resources/dart2/serialization/native/native_class.mustache
patch --no-backup-if-mismatch -u native_class.mustache <native_class.mustache.patch patch --no-backup-if-mismatch -u native_class.mustache <native_class.mustache.patch
patch --no-backup-if-mismatch -u native_class.mustache <native_class_nullable_items_in_arrays.patch
cd ../../ cd ../../
wget -O api.mustache https://raw.githubusercontent.com/OpenAPITools/openapi-generator/$OPENAPI_GENERATOR_VERSION/modules/openapi-generator/src/main/resources/dart2/api.mustache wget -O api.mustache https://raw.githubusercontent.com/OpenAPITools/openapi-generator/$OPENAPI_GENERATOR_VERSION/modules/openapi-generator/src/main/resources/dart2/api.mustache

View file

@ -7284,6 +7284,24 @@
"$ref": "#/components/schemas/AssetOrder" "$ref": "#/components/schemas/AssetOrder"
} }
}, },
{
"name": "page",
"required": false,
"in": "query",
"schema": {
"minimum": 1,
"type": "number"
}
},
{
"name": "pageSize",
"required": false,
"in": "query",
"schema": {
"minimum": 1,
"type": "number"
}
},
{ {
"name": "personId", "name": "personId",
"required": false, "required": false,
@ -7293,14 +7311,6 @@
"type": "string" "type": "string"
} }
}, },
{
"name": "size",
"required": true,
"in": "query",
"schema": {
"$ref": "#/components/schemas/TimeBucketSize"
}
},
{ {
"name": "tagId", "name": "tagId",
"required": false, "required": false,
@ -7357,10 +7367,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"items": { "$ref": "#/components/schemas/TimeBucketAssetResponseDto"
"$ref": "#/components/schemas/AssetResponseDto"
},
"type": "array"
} }
} }
}, },
@ -7437,14 +7444,6 @@
"type": "string" "type": "string"
} }
}, },
{
"name": "size",
"required": true,
"in": "query",
"schema": {
"$ref": "#/components/schemas/TimeBucketSize"
}
},
{ {
"name": "tagId", "name": "tagId",
"required": false, "required": false,
@ -7494,7 +7493,7 @@
"application/json": { "application/json": {
"schema": { "schema": {
"items": { "items": {
"$ref": "#/components/schemas/TimeBucketResponseDto" "$ref": "#/components/schemas/TimeBucketsResponseDto"
}, },
"type": "array" "type": "array"
} }
@ -14069,7 +14068,131 @@
], ],
"type": "object" "type": "object"
}, },
"TimeBucketResponseDto": { "TimeBucketAssetResponseDto": {
"properties": {
"city": {
"items": {
"nullable": true,
"type": "string"
},
"type": "array"
},
"country": {
"items": {
"nullable": true,
"type": "string"
},
"type": "array"
},
"duration": {
"items": {
"nullable": true,
"type": "string"
},
"type": "array"
},
"id": {
"items": {
"type": "string"
},
"type": "array"
},
"isFavorite": {
"items": {
"type": "boolean"
},
"type": "array"
},
"isImage": {
"items": {
"type": "boolean"
},
"type": "array"
},
"isTrashed": {
"items": {
"type": "boolean"
},
"type": "array"
},
"livePhotoVideoId": {
"items": {
"nullable": true,
"type": "string"
},
"type": "array"
},
"localDateTime": {
"items": {
"type": "string"
},
"type": "array"
},
"ownerId": {
"items": {
"type": "string"
},
"type": "array"
},
"projectionType": {
"items": {
"nullable": true,
"type": "string"
},
"type": "array"
},
"ratio": {
"items": {
"type": "number"
},
"type": "array"
},
"stack": {
"description": "(stack ID, stack asset count) tuple",
"items": {
"items": {
"type": "string"
},
"maxItems": 2,
"minItems": 2,
"nullable": true,
"type": "array"
},
"type": "array"
},
"thumbhash": {
"items": {
"nullable": true,
"type": "string"
},
"type": "array"
},
"visibility": {
"items": {
"$ref": "#/components/schemas/AssetVisibility"
},
"type": "array"
}
},
"required": [
"city",
"country",
"duration",
"id",
"isFavorite",
"isImage",
"isTrashed",
"livePhotoVideoId",
"localDateTime",
"ownerId",
"projectionType",
"ratio",
"thumbhash",
"visibility"
],
"type": "object"
},
"TimeBucketsResponseDto": {
"properties": { "properties": {
"count": { "count": {
"type": "integer" "type": "integer"
@ -14084,13 +14207,6 @@
], ],
"type": "object" "type": "object"
}, },
"TimeBucketSize": {
"enum": [
"DAY",
"MONTH"
],
"type": "string"
},
"ToneMapping": { "ToneMapping": {
"enum": [ "enum": [
"hable", "hable",

View file

@ -32,7 +32,7 @@ class {{{classname}}} {
{{/required}} {{/required}}
{{/isNullable}} {{/isNullable}}
{{/isEnum}} {{/isEnum}}
{{{datatypeWithEnum}}}{{#isNullable}}?{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}?{{/defaultValue}}{{/required}}{{/isNullable}} {{{name}}}; {{#isArray}}{{#uniqueItems}}Set{{/uniqueItems}}{{^uniqueItems}}List{{/uniqueItems}}<{{{items.dataType}}}{{#items.isNullable}}?{{/items.isNullable}}>{{/isArray}}{{^isArray}}{{{datatypeWithEnum}}}{{/isArray}}{{#isNullable}}?{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}?{{/defaultValue}}{{/required}}{{/isNullable}} {{{name}}};
{{/vars}} {{/vars}}
@override @override

View file

@ -0,0 +1,13 @@
diff --git a/open-api/templates/mobile/serialization/native/native_class.mustache b/open-api/templates/mobile/serialization/native/native_class.mustache
index 9a7b1439b..9f40d5b0b 100644
--- a/open-api/templates/mobile/serialization/native/native_class.mustache
+++ b/open-api/templates/mobile/serialization/native/native_class.mustache
@@ -32,7 +32,7 @@ class {{{classname}}} {
{{/required}}
{{/isNullable}}
{{/isEnum}}
- {{{datatypeWithEnum}}}{{#isNullable}}?{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}?{{/defaultValue}}{{/required}}{{/isNullable}} {{{name}}};
+ {{#isArray}}{{#uniqueItems}}Set{{/uniqueItems}}{{^uniqueItems}}List{{/uniqueItems}}<{{{items.dataType}}}{{#items.isNullable}}?{{/items.isNullable}}>{{/isArray}}{{^isArray}}{{{datatypeWithEnum}}}{{/isArray}}{{#isNullable}}?{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}?{{/defaultValue}}{{/required}}{{/isNullable}} {{{name}}};
{{/vars}}
@override

View file

@ -1420,7 +1420,25 @@ export type TagBulkAssetsResponseDto = {
export type TagUpdateDto = { export type TagUpdateDto = {
color?: string | null; color?: string | null;
}; };
export type TimeBucketResponseDto = { export type TimeBucketAssetResponseDto = {
city: (string | null)[];
country: (string | null)[];
duration: (string | null)[];
id: string[];
isFavorite: boolean[];
isImage: boolean[];
isTrashed: boolean[];
livePhotoVideoId: (string | null)[];
localDateTime: string[];
ownerId: string[];
projectionType: (string | null)[];
ratio: number[];
/** (stack ID, stack asset count) tuple */
stack?: (string[] | null)[];
thumbhash: (string | null)[];
visibility: AssetVisibility[];
};
export type TimeBucketsResponseDto = {
count: number; count: number;
timeBucket: string; timeBucket: string;
}; };
@ -3367,14 +3385,15 @@ export function tagAssets({ id, bulkIdsDto }: {
body: bulkIdsDto body: bulkIdsDto
}))); })));
} }
export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, personId, size, tagId, timeBucket, userId, visibility, withPartners, withStacked }: { export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, page, pageSize, personId, tagId, timeBucket, userId, visibility, withPartners, withStacked }: {
albumId?: string; albumId?: string;
isFavorite?: boolean; isFavorite?: boolean;
isTrashed?: boolean; isTrashed?: boolean;
key?: string; key?: string;
order?: AssetOrder; order?: AssetOrder;
page?: number;
pageSize?: number;
personId?: string; personId?: string;
size: TimeBucketSize;
tagId?: string; tagId?: string;
timeBucket: string; timeBucket: string;
userId?: string; userId?: string;
@ -3384,15 +3403,16 @@ export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, pers
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 200; status: 200;
data: AssetResponseDto[]; data: TimeBucketAssetResponseDto;
}>(`/timeline/bucket${QS.query(QS.explode({ }>(`/timeline/bucket${QS.query(QS.explode({
albumId, albumId,
isFavorite, isFavorite,
isTrashed, isTrashed,
key, key,
order, order,
page,
pageSize,
personId, personId,
size,
tagId, tagId,
timeBucket, timeBucket,
userId, userId,
@ -3403,14 +3423,13 @@ export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, pers
...opts ...opts
})); }));
} }
export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, personId, size, tagId, userId, visibility, withPartners, withStacked }: { export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, personId, tagId, userId, visibility, withPartners, withStacked }: {
albumId?: string; albumId?: string;
isFavorite?: boolean; isFavorite?: boolean;
isTrashed?: boolean; isTrashed?: boolean;
key?: string; key?: string;
order?: AssetOrder; order?: AssetOrder;
personId?: string; personId?: string;
size: TimeBucketSize;
tagId?: string; tagId?: string;
userId?: string; userId?: string;
visibility?: AssetVisibility; visibility?: AssetVisibility;
@ -3419,7 +3438,7 @@ export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, per
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 200; status: 200;
data: TimeBucketResponseDto[]; data: TimeBucketsResponseDto[];
}>(`/timeline/buckets${QS.query(QS.explode({ }>(`/timeline/buckets${QS.query(QS.explode({
albumId, albumId,
isFavorite, isFavorite,
@ -3427,7 +3446,6 @@ export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, per
key, key,
order, order,
personId, personId,
size,
tagId, tagId,
userId, userId,
visibility, visibility,
@ -3921,7 +3939,3 @@ export enum OAuthTokenEndpointAuthMethod {
ClientSecretPost = "client_secret_post", ClientSecretPost = "client_secret_post",
ClientSecretBasic = "client_secret_basic" ClientSecretBasic = "client_secret_basic"
} }
export enum TimeBucketSize {
Day = "DAY",
Month = "MONTH"
}

View file

@ -72,7 +72,9 @@ class SqlGenerator {
await rm(this.options.targetDir, { force: true, recursive: true }); await rm(this.options.targetDir, { force: true, recursive: true });
await mkdir(this.options.targetDir); await mkdir(this.options.targetDir);
process.env.DB_HOSTNAME = 'localhost'; if (!process.env.DB_HOSTNAME) {
process.env.DB_HOSTNAME = 'localhost';
}
const { database, cls, otel } = new ConfigRepository().getEnv(); const { database, cls, otel } = new ConfigRepository().getEnv();
const moduleFixture = await Test.createTestingModule({ const moduleFixture = await Test.createTestingModule({

View file

@ -1,8 +1,7 @@
import { Controller, Get, Query } from '@nestjs/common'; import { Controller, Get, Header, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dtos/time-bucket.dto'; import { TimeBucketAssetDto, TimeBucketAssetResponseDto, TimeBucketDto } from 'src/dtos/time-bucket.dto';
import { Permission } from 'src/enum'; import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { TimelineService } from 'src/services/timeline.service'; import { TimelineService } from 'src/services/timeline.service';
@ -14,13 +13,15 @@ export class TimelineController {
@Get('buckets') @Get('buckets')
@Authenticated({ permission: Permission.ASSET_READ, sharedLink: true }) @Authenticated({ permission: Permission.ASSET_READ, sharedLink: true })
getTimeBuckets(@Auth() auth: AuthDto, @Query() dto: TimeBucketDto): Promise<TimeBucketResponseDto[]> { getTimeBuckets(@Auth() auth: AuthDto, @Query() dto: TimeBucketDto) {
return this.service.getTimeBuckets(auth, dto); return this.service.getTimeBuckets(auth, dto);
} }
@Get('bucket') @Get('bucket')
@Authenticated({ permission: Permission.ASSET_READ, sharedLink: true }) @Authenticated({ permission: Permission.ASSET_READ, sharedLink: true })
getTimeBucket(@Auth() auth: AuthDto, @Query() dto: TimeBucketAssetDto): Promise<AssetResponseDto[]> { @ApiOkResponse({ type: TimeBucketAssetResponseDto })
return this.service.getTimeBucket(auth, dto) as Promise<AssetResponseDto[]>; @Header('Content-Type', 'application/json')
getTimeBucket(@Auth() auth: AuthDto, @Query() dto: TimeBucketAssetDto) {
return this.service.getTimeBucket(auth, dto);
} }
} }

View file

@ -13,6 +13,7 @@ import {
import { TagResponseDto, mapTag } from 'src/dtos/tag.dto'; import { TagResponseDto, mapTag } from 'src/dtos/tag.dto';
import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
import { AssetStatus, AssetType, AssetVisibility } from 'src/enum'; import { AssetStatus, AssetType, AssetVisibility } from 'src/enum';
import { hexOrBufferToBase64 } from 'src/utils/bytes';
import { mimeTypes } from 'src/utils/mime-types'; import { mimeTypes } from 'src/utils/mime-types';
export class SanitizedAssetResponseDto { export class SanitizedAssetResponseDto {
@ -140,15 +141,6 @@ const mapStack = (entity: { stack?: Stack | null }) => {
}; };
}; };
// if an asset is jsonified in the DB before being returned, its buffer fields will be hex-encoded strings
export const hexOrBufferToBase64 = (encoded: string | Buffer) => {
if (typeof encoded === 'string') {
return Buffer.from(encoded.slice(2), 'hex').toString('base64');
}
return encoded.toString('base64');
};
export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): AssetResponseDto { export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): AssetResponseDto {
const { stripMetadata = false, withStack = false } = options; const { stripMetadata = false, withStack = false } = options;
@ -192,7 +184,7 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset
tags: entity.tags?.map((tag) => mapTag(tag)), tags: entity.tags?.map((tag) => mapTag(tag)),
people: peopleWithFaces(entity.faces), people: peopleWithFaces(entity.faces),
unassignedFaces: entity.faces?.filter((face) => !face.person).map((a) => mapFacesWithoutPerson(a)), unassignedFaces: entity.faces?.filter((face) => !face.person).map((a) => mapFacesWithoutPerson(a)),
checksum: hexOrBufferToBase64(entity.checksum), checksum: hexOrBufferToBase64(entity.checksum)!,
stack: withStack ? mapStack(entity) : undefined, stack: withStack ? mapStack(entity) : undefined,
isOffline: entity.isOffline, isOffline: entity.isOffline,
hasMetadata: true, hasMetadata: true,

View file

@ -1,15 +1,10 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
import { IsEnum, IsInt, IsString, Min } from 'class-validator';
import { AssetOrder, AssetVisibility } from 'src/enum'; import { AssetOrder, AssetVisibility } from 'src/enum';
import { TimeBucketSize } from 'src/repositories/asset.repository';
import { Optional, ValidateAssetVisibility, ValidateBoolean, ValidateUUID } from 'src/validation'; import { Optional, ValidateAssetVisibility, ValidateBoolean, ValidateUUID } from 'src/validation';
export class TimeBucketDto { export class TimeBucketDto {
@IsNotEmpty()
@IsEnum(TimeBucketSize)
@ApiProperty({ enum: TimeBucketSize, enumName: 'TimeBucketSize' })
size!: TimeBucketSize;
@ValidateUUID({ optional: true }) @ValidateUUID({ optional: true })
userId?: string; userId?: string;
@ -46,9 +41,75 @@ export class TimeBucketDto {
export class TimeBucketAssetDto extends TimeBucketDto { export class TimeBucketAssetDto extends TimeBucketDto {
@IsString() @IsString()
timeBucket!: string; timeBucket!: string;
@IsInt()
@Min(1)
@Optional()
page?: number;
@IsInt()
@Min(1)
@Optional()
pageSize?: number;
} }
export class TimeBucketResponseDto { export class TimelineStackResponseDto {
id!: string;
primaryAssetId!: string;
assetCount!: number;
}
export class TimeBucketAssetResponseDto {
id!: string[];
ownerId!: string[];
ratio!: number[];
isFavorite!: boolean[];
@ApiProperty({ enum: AssetVisibility, enumName: 'AssetVisibility', isArray: true })
visibility!: AssetVisibility[];
isTrashed!: boolean[];
isImage!: boolean[];
@ApiProperty({ type: 'array', items: { type: 'string', nullable: true } })
thumbhash!: (string | null)[];
localDateTime!: string[];
@ApiProperty({ type: 'array', items: { type: 'string', nullable: true } })
duration!: (string | null)[];
@ApiProperty({
type: 'array',
items: {
type: 'array',
items: { type: 'string' },
minItems: 2,
maxItems: 2,
nullable: true,
},
description: '(stack ID, stack asset count) tuple',
})
stack?: ([string, string] | null)[];
@ApiProperty({ type: 'array', items: { type: 'string', nullable: true } })
projectionType!: (string | null)[];
@ApiProperty({ type: 'array', items: { type: 'string', nullable: true } })
livePhotoVideoId!: (string | null)[];
@ApiProperty({ type: 'array', items: { type: 'string', nullable: true } })
city!: (string | null)[];
@ApiProperty({ type: 'array', items: { type: 'string', nullable: true } })
country!: (string | null)[];
}
export class TimeBucketsResponseDto {
@ApiProperty({ type: 'string' }) @ApiProperty({ type: 'string' })
timeBucket!: string; timeBucket!: string;

View file

@ -235,14 +235,14 @@ limit
with with
"assets" as ( "assets" as (
select select
date_trunc($1, "localDateTime" at time zone 'UTC') at time zone 'UTC' as "timeBucket" date_trunc('MONTH', "localDateTime" at time zone 'UTC') at time zone 'UTC' as "timeBucket"
from from
"assets" "assets"
where where
"assets"."deletedAt" is null "assets"."deletedAt" is null
and ( and (
"assets"."visibility" = $2 "assets"."visibility" = $1
or "assets"."visibility" = $3 or "assets"."visibility" = $2
) )
) )
select select
@ -256,40 +256,101 @@ order by
"timeBucket" desc "timeBucket" desc
-- AssetRepository.getTimeBucket -- AssetRepository.getTimeBucket
select with
"assets".*, "cte" as (
to_json("exif") as "exifInfo",
to_json("stacked_assets") as "stack"
from
"assets"
left join "exif" on "assets"."id" = "exif"."assetId"
left join "asset_stack" on "asset_stack"."id" = "assets"."stackId"
left join lateral (
select select
"asset_stack".*, "assets"."duration",
count("stacked") as "assetCount" "assets"."id",
"assets"."visibility",
"assets"."isFavorite",
assets.type = 'IMAGE' as "isImage",
assets."deletedAt" is null as "isTrashed",
"assets"."livePhotoVideoId",
"assets"."localDateTime",
"assets"."ownerId",
"assets"."status",
encode("assets"."thumbhash", 'base64') as "thumbhash",
"exif"."city",
"exif"."country",
"exif"."projectionType",
coalesce(
case
when exif."exifImageHeight" = 0
or exif."exifImageWidth" = 0 then 1
when "exif"."orientation" in ('5', '6', '7', '8', '-90', '90') then round(
exif."exifImageHeight"::numeric / exif."exifImageWidth"::numeric,
3
)
else round(
exif."exifImageWidth"::numeric / exif."exifImageHeight"::numeric,
3
)
end,
1
) as "ratio",
"stack"
from from
"assets" as "stacked" "assets"
inner join "exif" on "assets"."id" = "exif"."assetId"
left join lateral (
select
array[stacked."stackId"::text, count('stacked')::text] as "stack"
from
"assets" as "stacked"
where
"stacked"."stackId" = "assets"."stackId"
and "stacked"."deletedAt" is null
and "stacked"."visibility" != $1
group by
"stacked"."stackId"
) as "stacked_assets" on true
where where
"stacked"."stackId" = "asset_stack"."id" "assets"."deletedAt" is null
and "stacked"."deletedAt" is null and (
and "stacked"."visibility" != $1 "assets"."visibility" = $2
group by or "assets"."visibility" = $3
"asset_stack"."id" )
) as "stacked_assets" on "asset_stack"."id" is not null and date_trunc('MONTH', "localDateTime" at time zone 'UTC') at time zone 'UTC' = $4
where and (
( "assets"."visibility" = $5
"asset_stack"."primaryAssetId" = "assets"."id" or "assets"."visibility" = $6
or "assets"."stackId" is null )
and not exists (
select
from
"asset_stack"
where
"asset_stack"."id" = "assets"."stackId"
and "asset_stack"."primaryAssetId" != "assets"."id"
)
order by
"assets"."localDateTime" desc
),
"agg" as (
select
coalesce(array_agg("city"), '{}') as "city",
coalesce(array_agg("country"), '{}') as "country",
coalesce(array_agg("duration"), '{}') as "duration",
coalesce(array_agg("id"), '{}') as "id",
coalesce(array_agg("visibility"), '{}') as "visibility",
coalesce(array_agg("isFavorite"), '{}') as "isFavorite",
coalesce(array_agg("isImage"), '{}') as "isImage",
coalesce(array_agg("isTrashed"), '{}') as "isTrashed",
coalesce(array_agg("livePhotoVideoId"), '{}') as "livePhotoVideoId",
coalesce(array_agg("localDateTime"), '{}') as "localDateTime",
coalesce(array_agg("ownerId"), '{}') as "ownerId",
coalesce(array_agg("projectionType"), '{}') as "projectionType",
coalesce(array_agg("ratio"), '{}') as "ratio",
coalesce(array_agg("status"), '{}') as "status",
coalesce(array_agg("thumbhash"), '{}') as "thumbhash",
coalesce(json_agg("stack"), '[]') as "stack"
from
"cte"
) )
and "assets"."deletedAt" is null select
and ( to_json(agg)::text as "assets"
"assets"."visibility" = $2 from
or "assets"."visibility" = $3 "agg"
)
and date_trunc($4, "localDateTime" at time zone 'UTC') at time zone 'UTC' = $5
order by
"assets"."localDateTime" desc
-- AssetRepository.getDuplicates -- AssetRepository.getDuplicates
with with

View file

@ -68,7 +68,6 @@ export interface AssetBuilderOptions {
} }
export interface TimeBucketOptions extends AssetBuilderOptions { export interface TimeBucketOptions extends AssetBuilderOptions {
size: TimeBucketSize;
order?: AssetOrder; order?: AssetOrder;
} }
@ -539,7 +538,7 @@ export class AssetRepository {
.with('assets', (qb) => .with('assets', (qb) =>
qb qb
.selectFrom('assets') .selectFrom('assets')
.select(truncatedDate<Date>(options.size).as('timeBucket')) .select(truncatedDate<Date>(TimeBucketSize.MONTH).as('timeBucket'))
.$if(!!options.isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED)) .$if(!!options.isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED))
.where('assets.deletedAt', options.isTrashed ? 'is not' : 'is', null) .where('assets.deletedAt', options.isTrashed ? 'is not' : 'is', null)
.$if(options.visibility === undefined, withDefaultVisibility) .$if(options.visibility === undefined, withDefaultVisibility)
@ -581,53 +580,126 @@ export class AssetRepository {
); );
} }
@GenerateSql({ params: [DummyValue.TIME_BUCKET, { size: TimeBucketSize.MONTH, withStacked: true }] }) @GenerateSql({
async getTimeBucket(timeBucket: string, options: TimeBucketOptions) { params: [DummyValue.TIME_BUCKET, { withStacked: true }],
return this.db })
.selectFrom('assets') getTimeBucket(timeBucket: string, options: TimeBucketOptions) {
.selectAll('assets') const query = this.db
.$call(withExif) .with('cte', (qb) =>
.$if(!!options.albumId, (qb) =>
qb qb
.innerJoin('albums_assets_assets', 'albums_assets_assets.assetsId', 'assets.id') .selectFrom('assets')
.where('albums_assets_assets.albumsId', '=', options.albumId!), .innerJoin('exif', 'assets.id', 'exif.assetId')
.select((eb) => [
'assets.duration',
'assets.id',
'assets.visibility',
'assets.isFavorite',
sql`assets.type = 'IMAGE'`.as('isImage'),
sql`assets."deletedAt" is null`.as('isTrashed'),
'assets.livePhotoVideoId',
'assets.localDateTime',
'assets.ownerId',
'assets.status',
eb.fn('encode', ['assets.thumbhash', sql.lit('base64')]).as('thumbhash'),
'exif.city',
'exif.country',
'exif.projectionType',
eb.fn
.coalesce(
eb
.case()
.when(sql`exif."exifImageHeight" = 0 or exif."exifImageWidth" = 0`)
.then(eb.lit(1))
.when('exif.orientation', 'in', sql<string>`('5', '6', '7', '8', '-90', '90')`)
.then(sql`round(exif."exifImageHeight"::numeric / exif."exifImageWidth"::numeric, 3)`)
.else(sql`round(exif."exifImageWidth"::numeric / exif."exifImageHeight"::numeric, 3)`)
.end(),
eb.lit(1),
)
.as('ratio'),
])
.where('assets.deletedAt', options.isTrashed ? 'is not' : 'is', null)
.$if(options.visibility == undefined, withDefaultVisibility)
.$if(!!options.visibility, (qb) => qb.where('assets.visibility', '=', options.visibility!))
.where(truncatedDate(TimeBucketSize.MONTH), '=', timeBucket.replace(/^[+-]/, ''))
.$if(!!options.albumId, (qb) =>
qb.where((eb) =>
eb.exists(
eb
.selectFrom('albums_assets_assets')
.whereRef('albums_assets_assets.assetsId', '=', 'assets.id')
.where('albums_assets_assets.albumsId', '=', asUuid(options.albumId!)),
),
),
)
.$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!]))
.$if(!!options.userIds, (qb) => qb.where('assets.ownerId', '=', anyUuid(options.userIds!)))
.$if(options.visibility == undefined, withDefaultVisibility)
.$if(!!options.visibility, (qb) => qb.where('assets.visibility', '=', options.visibility!))
.$if(options.isFavorite !== undefined, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!))
.$if(!!options.withStacked, (qb) =>
qb
.where((eb) =>
eb.not(
eb.exists(
eb
.selectFrom('asset_stack')
.whereRef('asset_stack.id', '=', 'assets.stackId')
.whereRef('asset_stack.primaryAssetId', '!=', 'assets.id'),
),
),
)
.leftJoinLateral(
(eb) =>
eb
.selectFrom('assets as stacked')
.select(sql`array[stacked."stackId"::text, count('stacked')::text]`.as('stack'))
.whereRef('stacked.stackId', '=', 'assets.stackId')
.where('stacked.deletedAt', 'is', null)
.where('stacked.visibility', '!=', AssetVisibility.ARCHIVE)
.groupBy('stacked.stackId')
.as('stacked_assets'),
(join) => join.onTrue(),
)
.select('stack'),
)
.$if(!!options.assetType, (qb) => qb.where('assets.type', '=', options.assetType!))
.$if(options.isDuplicate !== undefined, (qb) =>
qb.where('assets.duplicateId', options.isDuplicate ? 'is not' : 'is', null),
)
.$if(!!options.isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED))
.$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!))
.orderBy('assets.localDateTime', options.order ?? 'desc'),
) )
.$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!])) .with('agg', (qb) =>
.$if(!!options.userIds, (qb) => qb.where('assets.ownerId', '=', anyUuid(options.userIds!)))
.$if(options.isFavorite !== undefined, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!))
.$if(!!options.withStacked, (qb) =>
qb qb
.leftJoin('asset_stack', 'asset_stack.id', 'assets.stackId') .selectFrom('cte')
.where((eb) => .select((eb) => [
eb.or([eb('asset_stack.primaryAssetId', '=', eb.ref('assets.id')), eb('assets.stackId', 'is', null)]), eb.fn.coalesce(eb.fn('array_agg', ['city']), sql.lit('{}')).as('city'),
) eb.fn.coalesce(eb.fn('array_agg', ['country']), sql.lit('{}')).as('country'),
.leftJoinLateral( eb.fn.coalesce(eb.fn('array_agg', ['duration']), sql.lit('{}')).as('duration'),
(eb) => eb.fn.coalesce(eb.fn('array_agg', ['id']), sql.lit('{}')).as('id'),
eb eb.fn.coalesce(eb.fn('array_agg', ['visibility']), sql.lit('{}')).as('visibility'),
.selectFrom('assets as stacked') eb.fn.coalesce(eb.fn('array_agg', ['isFavorite']), sql.lit('{}')).as('isFavorite'),
.selectAll('asset_stack') eb.fn.coalesce(eb.fn('array_agg', ['isImage']), sql.lit('{}')).as('isImage'),
.select((eb) => eb.fn.count(eb.table('stacked')).as('assetCount')) // TODO: isTrashed is redundant as it will always be all true or false depending on the options
.whereRef('stacked.stackId', '=', 'asset_stack.id') eb.fn.coalesce(eb.fn('array_agg', ['isTrashed']), sql.lit('{}')).as('isTrashed'),
.where('stacked.deletedAt', 'is', null) eb.fn.coalesce(eb.fn('array_agg', ['livePhotoVideoId']), sql.lit('{}')).as('livePhotoVideoId'),
.where('stacked.visibility', '!=', AssetVisibility.ARCHIVE) eb.fn.coalesce(eb.fn('array_agg', ['localDateTime']), sql.lit('{}')).as('localDateTime'),
.groupBy('asset_stack.id') eb.fn.coalesce(eb.fn('array_agg', ['ownerId']), sql.lit('{}')).as('ownerId'),
.as('stacked_assets'), eb.fn.coalesce(eb.fn('array_agg', ['projectionType']), sql.lit('{}')).as('projectionType'),
(join) => join.on('asset_stack.id', 'is not', null), eb.fn.coalesce(eb.fn('array_agg', ['ratio']), sql.lit('{}')).as('ratio'),
) eb.fn.coalesce(eb.fn('array_agg', ['status']), sql.lit('{}')).as('status'),
.select((eb) => eb.fn.toJson(eb.table('stacked_assets').$castTo<Stack | null>()).as('stack')), eb.fn.coalesce(eb.fn('array_agg', ['thumbhash']), sql.lit('{}')).as('thumbhash'),
])
.$if(!!options.withStacked, (qb) =>
qb.select((eb) => eb.fn.coalesce(eb.fn('json_agg', ['stack']), sql.lit('[]')).as('stack')),
),
) )
.$if(!!options.assetType, (qb) => qb.where('assets.type', '=', options.assetType!)) .selectFrom('agg')
.$if(options.isDuplicate !== undefined, (qb) => .select(sql<string>`to_json(agg)::text`.as('assets'));
qb.where('assets.duplicateId', options.isDuplicate ? 'is not' : 'is', null),
) return query.executeTakeFirstOrThrow();
.$if(!!options.isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED))
.$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!))
.where('assets.deletedAt', options.isTrashed ? 'is not' : 'is', null)
.$if(options.visibility == undefined, withDefaultVisibility)
.$if(!!options.visibility, (qb) => qb.where('assets.visibility', '=', options.visibility!))
.where(truncatedDate(options.size), '=', timeBucket.replace(/^[+-]/, ''))
.orderBy('assets.localDateTime', options.order ?? 'desc')
.execute();
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })

View file

@ -4,7 +4,7 @@ import { DateTime } from 'luxon';
import { Writable } from 'node:stream'; import { Writable } from 'node:stream';
import { AUDIT_LOG_MAX_DURATION } from 'src/constants'; import { AUDIT_LOG_MAX_DURATION } from 'src/constants';
import { SessionSyncCheckpoints } from 'src/db'; import { SessionSyncCheckpoints } from 'src/db';
import { AssetResponseDto, hexOrBufferToBase64, mapAsset } from 'src/dtos/asset-response.dto'; import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { import {
AssetDeltaSyncDto, AssetDeltaSyncDto,
@ -18,6 +18,7 @@ import { AssetVisibility, DatabaseAction, EntityType, Permission, SyncEntityType
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { SyncAck } from 'src/types'; import { SyncAck } from 'src/types';
import { getMyPartnerIds } from 'src/utils/asset.util'; import { getMyPartnerIds } from 'src/utils/asset.util';
import { hexOrBufferToBase64 } from 'src/utils/bytes';
import { setIsEqual } from 'src/utils/set'; import { setIsEqual } from 'src/utils/set';
import { fromAck, serialize } from 'src/utils/sync'; import { fromAck, serialize } from 'src/utils/sync';

View file

@ -1,10 +1,7 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { AssetVisibility } from 'src/enum'; import { AssetVisibility } from 'src/enum';
import { TimeBucketSize } from 'src/repositories/asset.repository';
import { TimelineService } from 'src/services/timeline.service'; import { TimelineService } from 'src/services/timeline.service';
import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { factory } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils'; import { newTestService, ServiceMocks } from 'test/utils';
describe(TimelineService.name, () => { describe(TimelineService.name, () => {
@ -19,13 +16,10 @@ describe(TimelineService.name, () => {
it("should return buckets if userId and albumId aren't set", async () => { it("should return buckets if userId and albumId aren't set", async () => {
mocks.asset.getTimeBuckets.mockResolvedValue([{ timeBucket: 'bucket', count: 1 }]); mocks.asset.getTimeBuckets.mockResolvedValue([{ timeBucket: 'bucket', count: 1 }]);
await expect( await expect(sut.getTimeBuckets(authStub.admin, {})).resolves.toEqual(
sut.getTimeBuckets(authStub.admin, { expect.arrayContaining([{ timeBucket: 'bucket', count: 1 }]),
size: TimeBucketSize.DAY, );
}),
).resolves.toEqual(expect.arrayContaining([{ timeBucket: 'bucket', count: 1 }]));
expect(mocks.asset.getTimeBuckets).toHaveBeenCalledWith({ expect(mocks.asset.getTimeBuckets).toHaveBeenCalledWith({
size: TimeBucketSize.DAY,
userIds: [authStub.admin.user.id], userIds: [authStub.admin.user.id],
}); });
}); });
@ -34,35 +28,34 @@ describe(TimelineService.name, () => {
describe('getTimeBucket', () => { describe('getTimeBucket', () => {
it('should return the assets for a album time bucket if user has album.read', async () => { it('should return the assets for a album time bucket if user has album.read', async () => {
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id']));
mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]); const json = `[{ id: ['asset-id'] }]`;
mocks.asset.getTimeBucket.mockResolvedValue({ assets: json });
await expect( await expect(sut.getTimeBucket(authStub.admin, { timeBucket: 'bucket', albumId: 'album-id' })).resolves.toEqual(
sut.getTimeBucket(authStub.admin, { size: TimeBucketSize.DAY, timeBucket: 'bucket', albumId: 'album-id' }), json,
).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); );
expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-id'])); expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-id']));
expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', { expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', {
size: TimeBucketSize.DAY,
timeBucket: 'bucket', timeBucket: 'bucket',
albumId: 'album-id', albumId: 'album-id',
}); });
}); });
it('should return the assets for a archive time bucket if user has archive.read', async () => { it('should return the assets for a archive time bucket if user has archive.read', async () => {
mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]); const json = `[{ id: ['asset-id'] }]`;
mocks.asset.getTimeBucket.mockResolvedValue({ assets: json });
await expect( await expect(
sut.getTimeBucket(authStub.admin, { sut.getTimeBucket(authStub.admin, {
size: TimeBucketSize.DAY,
timeBucket: 'bucket', timeBucket: 'bucket',
visibility: AssetVisibility.ARCHIVE, visibility: AssetVisibility.ARCHIVE,
userId: authStub.admin.user.id, userId: authStub.admin.user.id,
}), }),
).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); ).resolves.toEqual(json);
expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith( expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith(
'bucket', 'bucket',
expect.objectContaining({ expect.objectContaining({
size: TimeBucketSize.DAY,
timeBucket: 'bucket', timeBucket: 'bucket',
visibility: AssetVisibility.ARCHIVE, visibility: AssetVisibility.ARCHIVE,
userIds: [authStub.admin.user.id], userIds: [authStub.admin.user.id],
@ -71,20 +64,19 @@ describe(TimelineService.name, () => {
}); });
it('should include partner shared assets', async () => { it('should include partner shared assets', async () => {
mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]); const json = `[{ id: ['asset-id'] }]`;
mocks.asset.getTimeBucket.mockResolvedValue({ assets: json });
mocks.partner.getAll.mockResolvedValue([]); mocks.partner.getAll.mockResolvedValue([]);
await expect( await expect(
sut.getTimeBucket(authStub.admin, { sut.getTimeBucket(authStub.admin, {
size: TimeBucketSize.DAY,
timeBucket: 'bucket', timeBucket: 'bucket',
visibility: AssetVisibility.TIMELINE, visibility: AssetVisibility.TIMELINE,
userId: authStub.admin.user.id, userId: authStub.admin.user.id,
withPartners: true, withPartners: true,
}), }),
).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); ).resolves.toEqual(json);
expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', { expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', {
size: TimeBucketSize.DAY,
timeBucket: 'bucket', timeBucket: 'bucket',
visibility: AssetVisibility.TIMELINE, visibility: AssetVisibility.TIMELINE,
withPartners: true, withPartners: true,
@ -93,62 +85,37 @@ describe(TimelineService.name, () => {
}); });
it('should check permissions to read tag', async () => { it('should check permissions to read tag', async () => {
mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]); const json = `[{ id: ['asset-id'] }]`;
mocks.asset.getTimeBucket.mockResolvedValue({ assets: json });
mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-123'])); mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-123']));
await expect( await expect(
sut.getTimeBucket(authStub.admin, { sut.getTimeBucket(authStub.admin, {
size: TimeBucketSize.DAY,
timeBucket: 'bucket', timeBucket: 'bucket',
userId: authStub.admin.user.id, userId: authStub.admin.user.id,
tagId: 'tag-123', tagId: 'tag-123',
}), }),
).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); ).resolves.toEqual(json);
expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', { expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', {
size: TimeBucketSize.DAY,
tagId: 'tag-123', tagId: 'tag-123',
timeBucket: 'bucket', timeBucket: 'bucket',
userIds: [authStub.admin.user.id], userIds: [authStub.admin.user.id],
}); });
}); });
it('should strip metadata if showExif is disabled', async () => {
mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-id']));
mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]);
const auth = factory.auth({ sharedLink: { showExif: false } });
const buckets = await sut.getTimeBucket(auth, {
size: TimeBucketSize.DAY,
timeBucket: 'bucket',
visibility: AssetVisibility.ARCHIVE,
albumId: 'album-id',
});
expect(buckets).toEqual([expect.objectContaining({ id: 'asset-id' })]);
expect(buckets[0]).not.toHaveProperty('exif');
expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', {
size: TimeBucketSize.DAY,
timeBucket: 'bucket',
visibility: AssetVisibility.ARCHIVE,
albumId: 'album-id',
});
});
it('should return the assets for a library time bucket if user has library.read', async () => { it('should return the assets for a library time bucket if user has library.read', async () => {
mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]); const json = `[{ id: ['asset-id'] }]`;
mocks.asset.getTimeBucket.mockResolvedValue({ assets: json });
await expect( await expect(
sut.getTimeBucket(authStub.admin, { sut.getTimeBucket(authStub.admin, {
size: TimeBucketSize.DAY,
timeBucket: 'bucket', timeBucket: 'bucket',
userId: authStub.admin.user.id, userId: authStub.admin.user.id,
}), }),
).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); ).resolves.toEqual(json);
expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith( expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith(
'bucket', 'bucket',
expect.objectContaining({ expect.objectContaining({
size: TimeBucketSize.DAY,
timeBucket: 'bucket', timeBucket: 'bucket',
userIds: [authStub.admin.user.id], userIds: [authStub.admin.user.id],
}), }),
@ -158,7 +125,6 @@ describe(TimelineService.name, () => {
it('should throw an error if withParners is true and visibility true or undefined', async () => { it('should throw an error if withParners is true and visibility true or undefined', async () => {
await expect( await expect(
sut.getTimeBucket(authStub.admin, { sut.getTimeBucket(authStub.admin, {
size: TimeBucketSize.DAY,
timeBucket: 'bucket', timeBucket: 'bucket',
visibility: AssetVisibility.ARCHIVE, visibility: AssetVisibility.ARCHIVE,
withPartners: true, withPartners: true,
@ -168,7 +134,6 @@ describe(TimelineService.name, () => {
await expect( await expect(
sut.getTimeBucket(authStub.admin, { sut.getTimeBucket(authStub.admin, {
size: TimeBucketSize.DAY,
timeBucket: 'bucket', timeBucket: 'bucket',
visibility: undefined, visibility: undefined,
withPartners: true, withPartners: true,
@ -180,7 +145,6 @@ describe(TimelineService.name, () => {
it('should throw an error if withParners is true and isFavorite is either true or false', async () => { it('should throw an error if withParners is true and isFavorite is either true or false', async () => {
await expect( await expect(
sut.getTimeBucket(authStub.admin, { sut.getTimeBucket(authStub.admin, {
size: TimeBucketSize.DAY,
timeBucket: 'bucket', timeBucket: 'bucket',
isFavorite: true, isFavorite: true,
withPartners: true, withPartners: true,
@ -190,7 +154,6 @@ describe(TimelineService.name, () => {
await expect( await expect(
sut.getTimeBucket(authStub.admin, { sut.getTimeBucket(authStub.admin, {
size: TimeBucketSize.DAY,
timeBucket: 'bucket', timeBucket: 'bucket',
isFavorite: false, isFavorite: false,
withPartners: true, withPartners: true,
@ -202,7 +165,6 @@ describe(TimelineService.name, () => {
it('should throw an error if withParners is true and isTrash is true', async () => { it('should throw an error if withParners is true and isTrash is true', async () => {
await expect( await expect(
sut.getTimeBucket(authStub.admin, { sut.getTimeBucket(authStub.admin, {
size: TimeBucketSize.DAY,
timeBucket: 'bucket', timeBucket: 'bucket',
isTrashed: true, isTrashed: true,
withPartners: true, withPartners: true,

View file

@ -1,7 +1,6 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { AssetResponseDto, SanitizedAssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dtos/time-bucket.dto'; import { TimeBucketAssetDto, TimeBucketDto, TimeBucketsResponseDto } from 'src/dtos/time-bucket.dto';
import { AssetVisibility, Permission } from 'src/enum'; import { AssetVisibility, Permission } from 'src/enum';
import { TimeBucketOptions } from 'src/repositories/asset.repository'; import { TimeBucketOptions } from 'src/repositories/asset.repository';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
@ -9,22 +8,20 @@ import { getMyPartnerIds } from 'src/utils/asset.util';
@Injectable() @Injectable()
export class TimelineService extends BaseService { export class TimelineService extends BaseService {
async getTimeBuckets(auth: AuthDto, dto: TimeBucketDto): Promise<TimeBucketResponseDto[]> { async getTimeBuckets(auth: AuthDto, dto: TimeBucketDto): Promise<TimeBucketsResponseDto[]> {
await this.timeBucketChecks(auth, dto); await this.timeBucketChecks(auth, dto);
const timeBucketOptions = await this.buildTimeBucketOptions(auth, dto); const timeBucketOptions = await this.buildTimeBucketOptions(auth, dto);
return this.assetRepository.getTimeBuckets(timeBucketOptions); return await this.assetRepository.getTimeBuckets(timeBucketOptions);
} }
async getTimeBucket( // pre-jsonified response
auth: AuthDto, async getTimeBucket(auth: AuthDto, dto: TimeBucketAssetDto): Promise<string> {
dto: TimeBucketAssetDto,
): Promise<AssetResponseDto[] | SanitizedAssetResponseDto[]> {
await this.timeBucketChecks(auth, dto); await this.timeBucketChecks(auth, dto);
const timeBucketOptions = await this.buildTimeBucketOptions(auth, dto); const timeBucketOptions = await this.buildTimeBucketOptions(auth, { ...dto });
const assets = await this.assetRepository.getTimeBucket(dto.timeBucket, timeBucketOptions);
return !auth.sharedLink || auth.sharedLink?.showExif // TODO: use id cursor for pagination
? assets.map((asset) => mapAsset(asset, { withStack: true, auth })) const bucket = await this.assetRepository.getTimeBucket(dto.timeBucket, timeBucketOptions);
: assets.map((asset) => mapAsset(asset, { stripMetadata: true, auth })); return bucket.assets;
} }
private async buildTimeBucketOptions(auth: AuthDto, dto: TimeBucketDto): Promise<TimeBucketOptions> { private async buildTimeBucketOptions(auth: AuthDto, dto: TimeBucketDto): Promise<TimeBucketOptions> {

View file

@ -22,3 +22,12 @@ export function asHumanReadable(bytes: number, precision = 1): string {
return `${remainder.toFixed(magnitude == 0 ? 0 : precision)} ${units[magnitude]}`; return `${remainder.toFixed(magnitude == 0 ? 0 : precision)} ${units[magnitude]}`;
} }
// if an asset is jsonified in the DB before being returned, its buffer fields will be hex-encoded strings
export const hexOrBufferToBase64 = (encoded: string | Buffer) => {
if (typeof encoded === 'string') {
return Buffer.from(encoded.slice(2), 'hex').toString('base64');
}
return encoded.toString('base64');
};

View file

@ -271,7 +271,7 @@ export function withTags(eb: ExpressionBuilder<DB, 'assets'>) {
} }
export function truncatedDate<O>(size: TimeBucketSize) { export function truncatedDate<O>(size: TimeBucketSize) {
return sql<O>`date_trunc(${size}, "localDateTime" at time zone 'UTC') at time zone 'UTC'`; return sql<O>`date_trunc(${sql.lit(size)}, "localDateTime" at time zone 'UTC') at time zone 'UTC'`;
} }
export function withTagId<O>(qb: SelectQueryBuilder<DB, 'assets', O>, tagId: string) { export function withTagId<O>(qb: SelectQueryBuilder<DB, 'assets', O>, tagId: string) {
@ -285,6 +285,7 @@ export function withTagId<O>(qb: SelectQueryBuilder<DB, 'assets', O>, tagId: str
), ),
); );
} }
const joinDeduplicationPlugin = new DeduplicateJoinsPlugin(); const joinDeduplicationPlugin = new DeduplicateJoinsPlugin();
/** TODO: This should only be used for search-related queries, not as a general purpose query builder */ /** TODO: This should only be used for search-related queries, not as a general purpose query builder */

View file

@ -14,7 +14,6 @@ import { LoggingRepository } from 'src/repositories/logging.repository';
import { bootstrapTelemetry } from 'src/repositories/telemetry.repository'; import { bootstrapTelemetry } from 'src/repositories/telemetry.repository';
import { ApiService } from 'src/services/api.service'; import { ApiService } from 'src/services/api.service';
import { isStartUpError, useSwagger } from 'src/utils/misc'; import { isStartUpError, useSwagger } from 'src/utils/misc';
async function bootstrap() { async function bootstrap() {
process.title = 'immich-api'; process.title = 'immich-api';

View file

@ -251,6 +251,10 @@ export const assetStub = {
duplicateId: null, duplicateId: null,
isOffline: false, isOffline: false,
stack: null, stack: null,
orientation: '',
projectionType: null,
height: 3840,
width: 2160,
visibility: AssetVisibility.TIMELINE, visibility: AssetVisibility.TIMELINE,
}), }),

View file

@ -0,0 +1,6 @@
{
"name": "typescript-sdk",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View file

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { AssetAction } from '$lib/constants'; import { AssetAction } from '$lib/constants';
import { modalManager } from '$lib/managers/modal-manager.svelte'; import { modalManager } from '$lib/managers/modal-manager.svelte';
import { keepThisDeleteOthers } from '$lib/utils/asset-utils'; import { keepThisDeleteOthers } from '$lib/utils/asset-utils';

View file

@ -5,7 +5,7 @@
import { modalManager } from '$lib/managers/modal-manager.svelte'; import { modalManager } from '$lib/managers/modal-manager.svelte';
import type { TimelineAsset } from '$lib/stores/assets-store.svelte'; import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { AssetVisibility, updateAssets, Visibility } from '@immich/sdk'; import { AssetVisibility, updateAssets } from '@immich/sdk';
import { mdiEyeOffOutline, mdiFolderMoveOutline } from '@mdi/js'; import { mdiEyeOffOutline, mdiFolderMoveOutline } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { OnAction, PreAction } from './action'; import type { OnAction, PreAction } from './action';
@ -17,7 +17,7 @@
} }
let { asset, onAction, preAction }: Props = $props(); let { asset, onAction, preAction }: Props = $props();
const isLocked = asset.visibility === Visibility.Locked; const isLocked = asset.visibility === AssetVisibility.Locked;
const toggleLockedVisibility = async () => { const toggleLockedVisibility = async () => {
const isConfirmed = await modalManager.showDialog({ const isConfirmed = await modalManager.showDialog({

View file

@ -5,7 +5,7 @@
import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils'; import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
import { timeToSeconds } from '$lib/utils/date-time'; import { timeToSeconds } from '$lib/utils/date-time';
import { getAltText } from '$lib/utils/thumbnail-util'; import { getAltText } from '$lib/utils/thumbnail-util';
import { AssetMediaSize, Visibility } from '@immich/sdk'; import { AssetMediaSize, AssetVisibility } from '@immich/sdk';
import { import {
mdiArchiveArrowDownOutline, mdiArchiveArrowDownOutline,
mdiCameraBurst, mdiCameraBurst,
@ -291,7 +291,7 @@
</div> </div>
{/if} {/if}
{#if !authManager.key && showArchiveIcon && asset.visibility === Visibility.Archive} {#if !authManager.key && showArchiveIcon && asset.visibility === AssetVisibility.Archive}
<div class={['absolute start-2', asset.isFavorite ? 'bottom-10' : 'bottom-2']}> <div class={['absolute start-2', asset.isFavorite ? 'bottom-10' : 'bottom-2']}>
<Icon path={mdiArchiveArrowDownOutline} size="24" class="text-white" /> <Icon path={mdiArchiveArrowDownOutline} size="24" class="text-white" />
</div> </div>

View file

@ -2,7 +2,7 @@
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import type { OnArchive } from '$lib/utils/actions'; import type { OnArchive } from '$lib/utils/actions';
import { archiveAssets } from '$lib/utils/asset-utils'; import { archiveAssets } from '$lib/utils/asset-utils';
import { AssetVisibility, Visibility } from '@immich/sdk'; import { AssetVisibility } from '@immich/sdk';
import { mdiArchiveArrowDownOutline, mdiArchiveArrowUpOutline, mdiTimerSand } from '@mdi/js'; import { mdiArchiveArrowDownOutline, mdiArchiveArrowUpOutline, mdiTimerSand } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte'; import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
@ -24,12 +24,12 @@
const { clearSelect, getOwnedAssets } = getAssetControlContext(); const { clearSelect, getOwnedAssets } = getAssetControlContext();
const handleArchive = async () => { const handleArchive = async () => {
const isArchived = unarchive ? Visibility.Timeline : Visibility.Archive; const isArchived = unarchive ? AssetVisibility.Timeline : AssetVisibility.Archive;
const assets = [...getOwnedAssets()].filter((asset) => asset.visibility !== isArchived); const assets = [...getOwnedAssets()].filter((asset) => asset.visibility !== isArchived);
loading = true; loading = true;
const ids = await archiveAssets(assets, isArchived as unknown as AssetVisibility); const ids = await archiveAssets(assets, isArchived as AssetVisibility);
if (ids) { if (ids) {
onArchive?.(ids, isArchived); onArchive?.(ids, isArchived ? AssetVisibility.Archive : AssetVisibility.Timeline);
clearSelect(); clearSelect();
} }
loading = false; loading = false;

View file

@ -1,6 +1,6 @@
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { resetSavedUser, user } from '$lib/stores/user.store'; import { resetSavedUser, user } from '$lib/stores/user.store';
import { Visibility } from '@immich/sdk'; import { AssetVisibility } from '@immich/sdk';
import { timelineAssetFactory } from '@test-data/factories/asset-factory'; import { timelineAssetFactory } from '@test-data/factories/asset-factory';
import { userAdminFactory } from '@test-data/factories/user-factory'; import { userAdminFactory } from '@test-data/factories/user-factory';
@ -13,10 +13,10 @@ describe('AssetInteraction', () => {
it('calculates derived values from selection', () => { it('calculates derived values from selection', () => {
assetInteraction.selectAsset( assetInteraction.selectAsset(
timelineAssetFactory.build({ isFavorite: true, visibility: Visibility.Archive, isTrashed: true }), timelineAssetFactory.build({ isFavorite: true, visibility: AssetVisibility.Archive, isTrashed: true }),
); );
assetInteraction.selectAsset( assetInteraction.selectAsset(
timelineAssetFactory.build({ isFavorite: true, visibility: Visibility.Timeline, isTrashed: false }), timelineAssetFactory.build({ isFavorite: true, visibility: AssetVisibility.Timeline, isTrashed: false }),
); );
expect(assetInteraction.selectionActive).toBe(true); expect(assetInteraction.selectionActive).toBe(true);

View file

@ -1,6 +1,6 @@
import type { TimelineAsset } from '$lib/stores/assets-store.svelte'; import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
import { user } from '$lib/stores/user.store'; import { user } from '$lib/stores/user.store';
import { Visibility, type UserAdminResponseDto } from '@immich/sdk'; import { AssetVisibility, type UserAdminResponseDto } from '@immich/sdk';
import { SvelteSet } from 'svelte/reactivity'; import { SvelteSet } from 'svelte/reactivity';
import { fromStore } from 'svelte/store'; import { fromStore } from 'svelte/store';
@ -21,7 +21,7 @@ export class AssetInteraction {
private userId = $derived(this.user.current?.id); private userId = $derived(this.user.current?.id);
isAllTrashed = $derived(this.selectedAssets.every((asset) => asset.isTrashed)); isAllTrashed = $derived(this.selectedAssets.every((asset) => asset.isTrashed));
isAllArchived = $derived(this.selectedAssets.every((asset) => asset.visibility === Visibility.Archive)); isAllArchived = $derived(this.selectedAssets.every((asset) => asset.visibility === AssetVisibility.Archive));
isAllFavorite = $derived(this.selectedAssets.every((asset) => asset.isFavorite)); isAllFavorite = $derived(this.selectedAssets.every((asset) => asset.isFavorite));
isAllUserOwned = $derived(this.selectedAssets.every((asset) => asset.ownerId === this.userId)); isAllUserOwned = $derived(this.selectedAssets.every((asset) => asset.ownerId === this.userId));

View file

@ -1,8 +1,8 @@
import { sdkMock } from '$lib/__mocks__/sdk.mock'; import { sdkMock } from '$lib/__mocks__/sdk.mock';
import { AbortError } from '$lib/utils'; import { AbortError } from '$lib/utils';
import { TimeBucketSize, type AssetResponseDto } from '@immich/sdk'; import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
import { assetFactory, timelineAssetFactory } from '@test-data/factories/asset-factory'; import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory';
import { AssetStore } from './assets-store.svelte'; import { AssetStore, type TimelineAsset } from './assets-store.svelte';
describe('AssetStore', () => { describe('AssetStore', () => {
beforeEach(() => { beforeEach(() => {
@ -11,18 +11,22 @@ describe('AssetStore', () => {
describe('init', () => { describe('init', () => {
let assetStore: AssetStore; let assetStore: AssetStore;
const bucketAssets: Record<string, AssetResponseDto[]> = { const bucketAssets: Record<string, TimelineAsset[]> = {
'2024-03-01T00:00:00.000Z': assetFactory '2024-03-01T00:00:00.000Z': timelineAssetFactory
.buildList(1) .buildList(1)
.map((asset) => ({ ...asset, localDateTime: '2024-03-01T00:00:00.000Z' })), .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) .buildList(100)
.map((asset) => ({ ...asset, localDateTime: '2024-02-01T00:00:00.000Z' })), .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) .buildList(3)
.map((asset) => ({ ...asset, localDateTime: '2024-01-01T00:00:00.000Z' })), .map((asset) => ({ ...asset, localDateTime: '2024-01-01T00:00:00.000Z' })),
}; };
const bucketAssetsResponse: Record<string, TimeBucketAssetResponseDto> = Object.fromEntries(
Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]),
);
beforeEach(async () => { beforeEach(async () => {
assetStore = new AssetStore(); assetStore = new AssetStore();
sdkMock.getTimeBuckets.mockResolvedValue([ sdkMock.getTimeBuckets.mockResolvedValue([
@ -30,13 +34,14 @@ describe('AssetStore', () => {
{ count: 100, timeBucket: '2024-02-01T00:00:00.000Z' }, { count: 100, timeBucket: '2024-02-01T00:00:00.000Z' },
{ count: 3, timeBucket: '2024-01-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 }); await assetStore.updateViewport({ width: 1588, height: 1000 });
}); });
it('should load buckets in viewport', () => { it('should load buckets in viewport', () => {
expect(sdkMock.getTimeBuckets).toBeCalledTimes(1); expect(sdkMock.getTimeBuckets).toBeCalledTimes(1);
expect(sdkMock.getTimeBuckets).toBeCalledWith({ size: TimeBucketSize.Month });
expect(sdkMock.getTimeBucket).toHaveBeenCalledTimes(2); expect(sdkMock.getTimeBucket).toHaveBeenCalledTimes(2);
}); });
@ -48,29 +53,31 @@ describe('AssetStore', () => {
expect(plainBuckets).toEqual( expect(plainBuckets).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ bucketDate: '2024-03-01T00:00:00.000Z', bucketHeight: 303 }), expect.objectContaining({ bucketDate: '2024-03-01T00:00:00.000Z', bucketHeight: 185.5 }),
expect.objectContaining({ bucketDate: '2024-02-01T00:00:00.000Z', bucketHeight: 4514.333_333_333_333 }), expect.objectContaining({ bucketDate: '2024-02-01T00:00:00.000Z', bucketHeight: 12_016 }),
expect.objectContaining({ bucketDate: '2024-01-01T00:00:00.000Z', bucketHeight: 286 }), expect.objectContaining({ bucketDate: '2024-01-01T00:00:00.000Z', bucketHeight: 286 }),
]), ]),
); );
}); });
it('calculates timeline height', () => { it('calculates timeline height', () => {
expect(assetStore.timelineHeight).toBe(5103.333_333_333_333); expect(assetStore.timelineHeight).toBe(12_487.5);
}); });
}); });
describe('loadBucket', () => { describe('loadBucket', () => {
let assetStore: AssetStore; let assetStore: AssetStore;
const bucketAssets: Record<string, AssetResponseDto[]> = { const bucketAssets: Record<string, TimelineAsset[]> = {
'2024-01-03T00:00:00.000Z': assetFactory '2024-01-03T00:00:00.000Z': timelineAssetFactory
.buildList(1) .buildList(1)
.map((asset) => ({ ...asset, localDateTime: '2024-03-01T00:00:00.000Z' })), .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) .buildList(3)
.map((asset) => ({ ...asset, localDateTime: '2024-01-01T00:00:00.000Z' })), .map((asset) => ({ ...asset, localDateTime: '2024-01-01T00:00:00.000Z' })),
}; };
const bucketAssetsResponse: Record<string, TimeBucketAssetResponseDto> = Object.fromEntries(
Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]),
);
beforeEach(async () => { beforeEach(async () => {
assetStore = new AssetStore(); assetStore = new AssetStore();
sdkMock.getTimeBuckets.mockResolvedValue([ sdkMock.getTimeBuckets.mockResolvedValue([
@ -82,7 +89,7 @@ describe('AssetStore', () => {
if (signal?.aborted) { if (signal?.aborted) {
throw new AbortError(); throw new AbortError();
} }
return bucketAssets[timeBucket]; return bucketAssetsResponse[timeBucket];
}); });
await assetStore.updateViewport({ width: 1588, height: 0 }); await assetStore.updateViewport({ width: 1588, height: 0 });
}); });
@ -296,7 +303,9 @@ describe('AssetStore', () => {
}); });
it('removes asset from bucket', () => { 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.addAssets([assetOne, assetTwo]);
assetStore.removeAssets([assetOne.id]); assetStore.removeAssets([assetOne.id]);
@ -342,17 +351,20 @@ describe('AssetStore', () => {
describe('getPreviousAsset', () => { describe('getPreviousAsset', () => {
let assetStore: AssetStore; let assetStore: AssetStore;
const bucketAssets: Record<string, AssetResponseDto[]> = { const bucketAssets: Record<string, TimelineAsset[]> = {
'2024-03-01T00:00:00.000Z': assetFactory '2024-03-01T00:00:00.000Z': timelineAssetFactory
.buildList(1) .buildList(1)
.map((asset) => ({ ...asset, localDateTime: '2024-03-01T00:00:00.000Z' })), .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) .buildList(6)
.map((asset) => ({ ...asset, localDateTime: '2024-02-01T00:00:00.000Z' })), .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) .buildList(3)
.map((asset) => ({ ...asset, localDateTime: '2024-01-01T00:00:00.000Z' })), .map((asset) => ({ ...asset, localDateTime: '2024-01-01T00:00:00.000Z' })),
}; };
const bucketAssetsResponse: Record<string, TimeBucketAssetResponseDto> = Object.fromEntries(
Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]),
);
beforeEach(async () => { beforeEach(async () => {
assetStore = new AssetStore(); assetStore = new AssetStore();
@ -361,8 +373,7 @@ describe('AssetStore', () => {
{ count: 6, timeBucket: '2024-02-01T00:00:00.000Z' }, { count: 6, timeBucket: '2024-02-01T00:00:00.000Z' },
{ count: 3, timeBucket: '2024-01-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 }); await assetStore.updateViewport({ width: 1588, height: 1000 });
}); });

View file

@ -1,4 +1,5 @@
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import { locale } from '$lib/stores/preferences.store'; import { locale } from '$lib/stores/preferences.store';
import { CancellableTask } from '$lib/utils/cancellable-task'; import { CancellableTask } from '$lib/utils/cancellable-task';
import { import {
@ -15,10 +16,8 @@ import {
getAssetInfo, getAssetInfo,
getTimeBucket, getTimeBucket,
getTimeBuckets, getTimeBuckets,
TimeBucketSize,
Visibility,
type AssetResponseDto,
type AssetStackResponseDto, type AssetStackResponseDto,
type TimeBucketAssetResponseDto,
} from '@immich/sdk'; } from '@immich/sdk';
import { clamp, debounce, isEqual, throttle } from 'lodash-es'; import { clamp, debounce, isEqual, throttle } from 'lodash-es';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
@ -32,6 +31,7 @@ const {
} = TUNABLES; } = TUNABLES;
type AssetApiGetTimeBucketsRequest = Parameters<typeof getTimeBuckets>[0]; type AssetApiGetTimeBucketsRequest = Parameters<typeof getTimeBuckets>[0];
export type AssetStoreOptions = Omit<AssetApiGetTimeBucketsRequest, 'size'> & { export type AssetStoreOptions = Omit<AssetApiGetTimeBucketsRequest, 'size'> & {
timelineAlbumId?: string; timelineAlbumId?: string;
deferInit?: boolean; deferInit?: boolean;
@ -75,7 +75,7 @@ export type TimelineAsset = {
ratio: number; ratio: number;
thumbhash: string | null; thumbhash: string | null;
localDateTime: string; localDateTime: string;
visibility: Visibility; visibility: AssetVisibility;
isFavorite: boolean; isFavorite: boolean;
isTrashed: boolean; isTrashed: boolean;
isVideo: boolean; isVideo: boolean;
@ -84,12 +84,11 @@ export type TimelineAsset = {
duration: string | null; duration: string | null;
projectionType: string | null; projectionType: string | null;
livePhotoVideoId: string | null; livePhotoVideoId: string | null;
text: { city: string | null;
city: string | null; country: string | null;
country: string | null; people: string[];
people: string[];
};
}; };
class IntersectingAsset { class IntersectingAsset {
// --- public --- // --- public ---
readonly #group: AssetDateGroup; readonly #group: AssetDateGroup;
@ -113,7 +112,7 @@ class IntersectingAsset {
}); });
position: CommonPosition | undefined = $state(); position: CommonPosition | undefined = $state();
asset: TimelineAsset | undefined = $state(); asset: TimelineAsset = <TimelineAsset>$state();
id: string | undefined = $derived(this.asset?.id); id: string | undefined = $derived(this.asset?.id);
constructor(group: AssetDateGroup, asset: TimelineAsset) { constructor(group: AssetDateGroup, asset: TimelineAsset) {
@ -121,9 +120,11 @@ class IntersectingAsset {
this.asset = asset; this.asset = asset;
} }
} }
type AssetOperation = (asset: TimelineAsset) => { remove: boolean }; type AssetOperation = (asset: TimelineAsset) => { remove: boolean };
type MoveAsset = { asset: TimelineAsset; year: number; month: number }; type MoveAsset = { asset: TimelineAsset; year: number; month: number };
export class AssetDateGroup { export class AssetDateGroup {
// --- public // --- public
readonly bucket: AssetBucket; readonly bucket: AssetBucket;
@ -166,6 +167,7 @@ export class AssetDateGroup {
getFirstAsset() { getFirstAsset() {
return this.intersetingAssets[0]?.asset; return this.intersetingAssets[0]?.asset;
} }
getRandomAsset() { getRandomAsset() {
const random = Math.floor(Math.random() * this.intersetingAssets.length); const random = Math.floor(Math.random() * this.intersetingAssets.length);
return this.intersetingAssets[random]; return this.intersetingAssets[random];
@ -243,6 +245,7 @@ export interface Viewport {
width: number; width: number;
height: number; height: number;
} }
export type ViewportXY = Viewport & { export type ViewportXY = Viewport & {
x: number; x: number;
y: number; y: number;
@ -250,11 +253,46 @@ export type ViewportXY = Viewport & {
class AddContext { class AddContext {
lookupCache: { lookupCache: {
[dayOfMonth: number]: AssetDateGroup; [year: number]: { [month: number]: { [day: number]: AssetDateGroup } };
} = {}; } = {};
unprocessedAssets: TimelineAsset[] = []; unprocessedAssets: TimelineAsset[] = [];
changedDateGroups = new Set<AssetDateGroup>(); changedDateGroups = new Set<AssetDateGroup>();
newDateGroups = new Set<AssetDateGroup>(); newDateGroups = new Set<AssetDateGroup>();
getDateGroup(year: number, month: number, day: number): AssetDateGroup | undefined {
return this.lookupCache[year]?.[month]?.[day];
}
setDateGroup(dateGroup: AssetDateGroup, year: number, month: number, day: number) {
if (!this.lookupCache[year]) {
this.lookupCache[year] = {};
}
if (!this.lookupCache[year][month]) {
this.lookupCache[year][month] = {};
}
this.lookupCache[year][month][day] = dateGroup;
}
get existingDateGroups() {
return this.changedDateGroups.difference(this.newDateGroups);
}
get updatedBuckets() {
const updated = new Set<AssetBucket>();
for (const group of this.changedDateGroups) {
updated.add(group.bucket);
}
return updated;
}
get bucketsWithNewDateGroups() {
const updated = new Set<AssetBucket>();
for (const group of this.newDateGroups) {
updated.add(group.bucket);
}
return updated;
}
sort(bucket: AssetBucket, sortOrder: AssetOrder = AssetOrder.Desc) { sort(bucket: AssetBucket, sortOrder: AssetOrder = AssetOrder.Desc) {
for (const group of this.changedDateGroups) { for (const group of this.changedDateGroups) {
group.sortAssets(sortOrder); group.sortAssets(sortOrder);
@ -267,6 +305,7 @@ class AddContext {
} }
} }
} }
export class AssetBucket { export class AssetBucket {
// --- public --- // --- public ---
#intersecting: boolean = $state(false); #intersecting: boolean = $state(false);
@ -331,6 +370,7 @@ export class AssetBucket {
this.handleLoadError, this.handleLoadError,
); );
} }
set intersecting(newValue: boolean) { set intersecting(newValue: boolean) {
const old = this.#intersecting; const old = this.#intersecting;
if (old !== newValue) { if (old !== newValue) {
@ -422,52 +462,74 @@ export class AssetBucket {
}; };
} }
// note - if the assets are not part of this bucket, they will not be added addAssets(bucketAssets: TimeBucketAssetResponseDto) {
addAssets(bucketResponse: AssetResponseDto[]) {
const addContext = new AddContext(); const addContext = new AddContext();
for (const asset of bucketResponse) { const people: string[] = [];
const timelineAsset = toTimelineAsset(asset); for (let i = 0; i < bucketAssets.id.length; i++) {
const timelineAsset: TimelineAsset = {
city: bucketAssets.city[i],
country: bucketAssets.country[i],
duration: bucketAssets.duration[i],
id: bucketAssets.id[i],
visibility: bucketAssets.visibility[i],
isFavorite: bucketAssets.isFavorite[i],
isImage: bucketAssets.isImage[i],
isTrashed: bucketAssets.isTrashed[i],
isVideo: !bucketAssets.isImage[i],
livePhotoVideoId: bucketAssets.livePhotoVideoId[i],
localDateTime: bucketAssets.localDateTime[i],
ownerId: bucketAssets.ownerId[i],
people,
projectionType: bucketAssets.projectionType[i],
ratio: bucketAssets.ratio[i],
stack: bucketAssets.stack?.[i]
? {
id: bucketAssets.stack[i]![0],
primaryAssetId: bucketAssets.id[i],
assetCount: Number.parseInt(bucketAssets.stack[i]![1]),
}
: null,
thumbhash: bucketAssets.thumbhash[i],
};
this.addTimelineAsset(timelineAsset, addContext); this.addTimelineAsset(timelineAsset, addContext);
} }
for (const group of addContext.existingDateGroups) {
group.sortAssets(this.#sortOrder);
}
if (addContext.newDateGroups.size > 0) {
this.sortDateGroups();
}
addContext.sort(this, this.#sortOrder); addContext.sort(this, this.#sortOrder);
return addContext.unprocessedAssets; return addContext.unprocessedAssets;
} }
addTimelineAsset(timelineAsset: TimelineAsset, addContext: AddContext) { addTimelineAsset(timelineAsset: TimelineAsset, addContext: AddContext) {
const { id, localDateTime } = timelineAsset; const { localDateTime } = timelineAsset;
const date = DateTime.fromISO(localDateTime).toUTC(); const date = DateTime.fromISO(localDateTime).toUTC();
const month = date.get('month'); const month = date.get('month');
const year = date.get('year'); const year = date.get('year');
// If the timeline asset does not belong to the current bucket, mark it as unprocessed
if (this.month !== month || this.year !== year) { if (this.month !== month || this.year !== year) {
addContext.unprocessedAssets.push(timelineAsset); addContext.unprocessedAssets.push(timelineAsset);
return; return;
} }
const day = date.get('day'); const day = date.get('day');
let dateGroup: AssetDateGroup | undefined = addContext.lookupCache[day] || this.findDateGroupByDay(day); let dateGroup = addContext.getDateGroup(year, month, day) || this.findDateGroupByDay(day);
if (dateGroup) { if (dateGroup) {
// Cache the found date group for future lookups addContext.setDateGroup(dateGroup, year, month, day);
addContext.lookupCache[day] = dateGroup;
} else { } else {
// Create a new date group if none exists for the given day
dateGroup = new AssetDateGroup(this, this.dateGroups.length, date, day); dateGroup = new AssetDateGroup(this, this.dateGroups.length, date, day);
this.dateGroups.push(dateGroup); this.dateGroups.push(dateGroup);
addContext.lookupCache[day] = dateGroup; addContext.setDateGroup(dateGroup, year, month, day);
addContext.newDateGroups.add(dateGroup); addContext.newDateGroups.add(dateGroup);
} }
// Check for duplicate assets in the date group
if (dateGroup.intersetingAssets.some((a) => a.id === id)) {
console.error(`Ignoring attempt to add duplicate asset ${id} to ${dateGroup.groupTitle}`);
return;
}
// Add the timeline asset to the date group
const intersectingAsset = new IntersectingAsset(dateGroup, timelineAsset); const intersectingAsset = new IntersectingAsset(dateGroup, timelineAsset);
dateGroup.intersetingAssets.push(intersectingAsset); dateGroup.intersetingAssets.push(intersectingAsset);
addContext.changedDateGroups.add(dateGroup); addContext.changedDateGroups.add(dateGroup);
@ -521,6 +583,7 @@ export class AssetBucket {
} }
} }
} }
get bucketHeight() { get bucketHeight() {
return this.#bucketHeight; return this.#bucketHeight;
} }
@ -909,7 +972,6 @@ export class AssetStore {
async #initialiazeTimeBuckets() { async #initialiazeTimeBuckets() {
const timebuckets = await getTimeBuckets({ const timebuckets = await getTimeBuckets({
...this.#options, ...this.#options,
size: TimeBucketSize.Month,
key: authManager.key, key: authManager.key,
}); });
@ -1016,6 +1078,7 @@ export class AssetStore {
rowWidth: Math.floor(viewportWidth), rowWidth: Math.floor(viewportWidth),
}; };
} }
#updateGeometry(bucket: AssetBucket, invalidateHeight: boolean) { #updateGeometry(bucket: AssetBucket, invalidateHeight: boolean) {
if (invalidateHeight) { if (invalidateHeight) {
bucket.isBucketHeightActual = false; bucket.isBucketHeightActual = false;
@ -1117,7 +1180,7 @@ export class AssetStore {
{ {
...this.#options, ...this.#options,
timeBucket: bucketDate, timeBucket: bucketDate,
size: TimeBucketSize.Month,
key: authManager.key, key: authManager.key,
}, },
{ signal }, { signal },
@ -1128,12 +1191,11 @@ export class AssetStore {
{ {
albumId: this.#options.timelineAlbumId, albumId: this.#options.timelineAlbumId,
timeBucket: bucketDate, timeBucket: bucketDate,
size: TimeBucketSize.Month,
key: authManager.key, key: authManager.key,
}, },
{ signal }, { signal },
); );
for (const { id } of albumAssets) { for (const id of albumAssets.id) {
this.albumAssets.add(id); this.albumAssets.add(id);
} }
} }
@ -1169,9 +1231,10 @@ export class AssetStore {
if (assets.length === 0) { if (assets.length === 0) {
return; return;
} }
const updatedBuckets = new Set<AssetBucket>();
const updatedDateGroups = new Set<AssetDateGroup>();
const addContext = new AddContext();
const updatedBuckets = new Set<AssetBucket>();
const bucketCount = this.buckets.length;
for (const asset of assets) { for (const asset of assets) {
const utc = DateTime.fromISO(asset.localDateTime).toUTC().startOf('month'); const utc = DateTime.fromISO(asset.localDateTime).toUTC().startOf('month');
const year = utc.get('year'); const year = utc.get('year');
@ -1182,20 +1245,26 @@ export class AssetStore {
bucket = new AssetBucket(this, utc, 1, this.#options.order); bucket = new AssetBucket(this, utc, 1, this.#options.order);
this.buckets.push(bucket); this.buckets.push(bucket);
} }
const addContext = new AddContext();
bucket.addTimelineAsset(asset, addContext); bucket.addTimelineAsset(asset, addContext);
addContext.sort(bucket, this.#options.order);
updatedBuckets.add(bucket); updatedBuckets.add(bucket);
} }
this.buckets.sort((a, b) => { if (this.buckets.length !== bucketCount) {
return a.year === b.year ? b.month - a.month : b.year - a.year; this.buckets.sort((a, b) => {
}); return a.year === b.year ? b.month - a.month : b.year - a.year;
});
for (const dateGroup of updatedDateGroups) {
dateGroup.sortAssets(this.#options.order);
} }
for (const bucket of updatedBuckets) {
for (const group of addContext.existingDateGroups) {
group.sortAssets(this.#options.order);
}
for (const bucket of addContext.bucketsWithNewDateGroups) {
bucket.sortDateGroups();
}
for (const bucket of addContext.updatedBuckets) {
bucket.sortDateGroups(); bucket.sortDateGroups();
this.#updateGeometry(bucket, true); this.#updateGeometry(bucket, true);
} }
@ -1421,7 +1490,7 @@ export class AssetStore {
isExcluded(asset: TimelineAsset) { isExcluded(asset: TimelineAsset) {
return ( return (
isMismatched(this.#options.visibility, asset.visibility as unknown as AssetVisibility) || isMismatched(this.#options.visibility, asset.visibility) ||
isMismatched(this.#options.isFavorite, asset.isFavorite) || isMismatched(this.#options.isFavorite, asset.isFavorite) ||
isMismatched(this.#options.isTrashed, asset.isTrashed) isMismatched(this.#options.isTrashed, asset.isTrashed)
); );

View file

@ -1,7 +1,7 @@
import { notificationController, NotificationType } from '$lib/components/shared-components/notification/notification'; import { notificationController, NotificationType } from '$lib/components/shared-components/notification/notification';
import type { AssetStore, TimelineAsset } from '$lib/stores/assets-store.svelte'; import type { AssetStore, TimelineAsset } from '$lib/stores/assets-store.svelte';
import type { StackResponse } from '$lib/utils/asset-utils'; import type { StackResponse } from '$lib/utils/asset-utils';
import { deleteAssets as deleteBulk, Visibility } from '@immich/sdk'; import { AssetVisibility, deleteAssets as deleteBulk } from '@immich/sdk';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { handleError } from './handle-error'; import { handleError } from './handle-error';
@ -11,7 +11,7 @@ export type OnRestore = (ids: string[]) => void;
export type OnLink = (assets: { still: TimelineAsset; motion: TimelineAsset }) => void; export type OnLink = (assets: { still: TimelineAsset; motion: TimelineAsset }) => void;
export type OnUnlink = (assets: { still: TimelineAsset; motion: TimelineAsset }) => void; export type OnUnlink = (assets: { still: TimelineAsset; motion: TimelineAsset }) => void;
export type OnAddToAlbum = (ids: string[], albumId: string) => void; export type OnAddToAlbum = (ids: string[], albumId: string) => void;
export type OnArchive = (ids: string[], visibility: Visibility) => void; export type OnArchive = (ids: string[], visibility: AssetVisibility) => void;
export type OnFavorite = (ids: string[], favorite: boolean) => void; export type OnFavorite = (ids: string[], favorite: boolean) => void;
export type OnStack = (result: StackResponse) => void; export type OnStack = (result: StackResponse) => void;
export type OnUnstack = (assets: TimelineAsset[]) => void; export type OnUnstack = (assets: TimelineAsset[]) => void;

View file

@ -1,6 +1,6 @@
import type { TimelineAsset } from '$lib/stores/assets-store.svelte'; import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
import { getAltText } from '$lib/utils/thumbnail-util'; import { getAltText } from '$lib/utils/thumbnail-util';
import { Visibility } from '@immich/sdk'; import { AssetVisibility } from '@immich/sdk';
import { init, register, waitLocale } from 'svelte-i18n'; import { init, register, waitLocale } from 'svelte-i18n';
interface Person { interface Person {
@ -62,7 +62,7 @@ describe('getAltText', () => {
ratio: 1, ratio: 1,
thumbhash: null, thumbhash: null,
localDateTime: '2024-01-01T12:00:00.000Z', localDateTime: '2024-01-01T12:00:00.000Z',
visibility: Visibility.Timeline, visibility: AssetVisibility.Timeline,
isFavorite: false, isFavorite: false,
isTrashed: false, isTrashed: false,
isVideo, isVideo,
@ -71,11 +71,9 @@ describe('getAltText', () => {
duration: null, duration: null,
projectionType: null, projectionType: null,
livePhotoVideoId: null, livePhotoVideoId: null,
text: { city: city ?? null,
city: city ?? null, country: country ?? null,
country: country ?? null, people: people?.map((person: Person) => person.name) ?? [],
people: people?.map((person: Person) => person.name) ?? [],
},
}; };
getAltText.subscribe((fn) => { getAltText.subscribe((fn) => {

View file

@ -41,19 +41,18 @@ export function getThumbnailSize(assetCount: number, viewWidth: number): number
export const getAltText = derived(t, ($t) => { export const getAltText = derived(t, ($t) => {
return (asset: TimelineAsset) => { return (asset: TimelineAsset) => {
const date = fromLocalDateTime(asset.localDateTime).toLocaleString({ dateStyle: 'long' }, { locale: get(locale) }); const date = fromLocalDateTime(asset.localDateTime).toLocaleString({ dateStyle: 'long' }, { locale: get(locale) });
const { city, country, people: names } = asset.text; const hasPlace = asset.city && asset.country;
const hasPlace = city && country;
const peopleCount = names.length; const peopleCount = asset.people.length;
const isVideo = asset.isVideo; const isVideo = asset.isVideo;
const values = { const values = {
date, date,
city, city: asset.city,
country, country: asset.country,
person1: names[0], person1: asset.people[0],
person2: names[1], person2: asset.people[1],
person3: names[2], person3: asset.people[2],
isVideo, isVideo,
additionalCount: peopleCount > 3 ? peopleCount - 2 : 0, additionalCount: peopleCount > 3 ? peopleCount - 2 : 0,
}; };

View file

@ -2,7 +2,8 @@ import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
import { locale } from '$lib/stores/preferences.store'; import { locale } from '$lib/stores/preferences.store';
import { getAssetRatio } from '$lib/utils/asset-utils'; import { getAssetRatio } from '$lib/utils/asset-utils';
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk'; import { AssetTypeEnum, AssetVisibility, type AssetResponseDto } from '@immich/sdk';
import { memoize } from 'lodash-es'; import { memoize } from 'lodash-es';
import { DateTime, type LocaleOptions } from 'luxon'; import { DateTime, type LocaleOptions } from 'luxon';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
@ -65,17 +66,12 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset):
if (isTimelineAsset(unknownAsset)) { if (isTimelineAsset(unknownAsset)) {
return unknownAsset; return unknownAsset;
} }
const assetResponse = unknownAsset as AssetResponseDto; const assetResponse = unknownAsset;
const { width, height } = getAssetRatio(assetResponse); const { width, height } = getAssetRatio(assetResponse);
const ratio = width / height; const ratio = width / height;
const city = assetResponse.exifInfo?.city; const city = assetResponse.exifInfo?.city;
const country = assetResponse.exifInfo?.country; const country = assetResponse.exifInfo?.country;
const people = assetResponse.people?.map((person) => person.name) || []; const people = assetResponse.people?.map((person) => person.name) || [];
const text = {
city: city || null,
country: country || null,
people,
};
return { return {
id: assetResponse.id, id: assetResponse.id,
ownerId: assetResponse.ownerId, ownerId: assetResponse.ownerId,
@ -83,7 +79,7 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset):
thumbhash: assetResponse.thumbhash, thumbhash: assetResponse.thumbhash,
localDateTime: assetResponse.localDateTime, localDateTime: assetResponse.localDateTime,
isFavorite: assetResponse.isFavorite, isFavorite: assetResponse.isFavorite,
visibility: assetResponse.visibility, visibility: assetResponse.isArchived ? AssetVisibility.Archive : AssetVisibility.Timeline,
isTrashed: assetResponse.isTrashed, isTrashed: assetResponse.isTrashed,
isVideo: assetResponse.type == AssetTypeEnum.Video, isVideo: assetResponse.type == AssetTypeEnum.Video,
isImage: assetResponse.type == AssetTypeEnum.Image, isImage: assetResponse.type == AssetTypeEnum.Image,
@ -91,8 +87,10 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset):
duration: assetResponse.duration || null, duration: assetResponse.duration || null,
projectionType: assetResponse.exifInfo?.projectionType || null, projectionType: assetResponse.exifInfo?.projectionType || null,
livePhotoVideoId: assetResponse.livePhotoVideoId || null, livePhotoVideoId: assetResponse.livePhotoVideoId || null,
text, city: city || null,
country: country || null,
people,
}; };
}; };
export const isTimelineAsset = (asset: AssetResponseDto | TimelineAsset): asset is TimelineAsset => export const isTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): unknownAsset is TimelineAsset =>
(asset as TimelineAsset).ratio !== undefined; (unknownAsset as TimelineAsset).ratio !== undefined;

View file

@ -1,6 +1,12 @@
import type { TimelineAsset } from '$lib/stores/assets-store.svelte'; import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
import { faker } from '@faker-js/faker'; import { faker } from '@faker-js/faker';
import { AssetTypeEnum, Visibility, type AssetResponseDto } from '@immich/sdk'; import {
AssetTypeEnum,
AssetVisibility,
Visibility,
type AssetResponseDto,
type TimeBucketAssetResponseDto,
} from '@immich/sdk';
import { Sync } from 'factory.ts'; import { Sync } from 'factory.ts';
export const assetFactory = Sync.makeFactory<AssetResponseDto>({ export const assetFactory = Sync.makeFactory<AssetResponseDto>({
@ -35,7 +41,7 @@ export const timelineAssetFactory = Sync.makeFactory<TimelineAsset>({
thumbhash: Sync.each(() => faker.string.alphanumeric(28)), thumbhash: Sync.each(() => faker.string.alphanumeric(28)),
localDateTime: Sync.each(() => faker.date.past().toISOString()), localDateTime: Sync.each(() => faker.date.past().toISOString()),
isFavorite: Sync.each(() => faker.datatype.boolean()), isFavorite: Sync.each(() => faker.datatype.boolean()),
visibility: Visibility.Timeline, visibility: AssetVisibility.Timeline,
isTrashed: false, isTrashed: false,
isImage: true, isImage: true,
isVideo: false, isVideo: false,
@ -43,9 +49,46 @@ export const timelineAssetFactory = Sync.makeFactory<TimelineAsset>({
stack: null, stack: null,
projectionType: null, projectionType: null,
livePhotoVideoId: Sync.each(() => faker.string.uuid()), livePhotoVideoId: Sync.each(() => faker.string.uuid()),
text: Sync.each(() => ({ city: faker.location.city(),
city: faker.location.city(), country: faker.location.country(),
country: faker.location.country(), people: [faker.person.fullName()],
people: [faker.person.fullName()],
})),
}); });
export const toResponseDto = (...timelineAsset: TimelineAsset[]) => {
const bucketAssets: TimeBucketAssetResponseDto = {
city: [],
country: [],
duration: [],
id: [],
visibility: [],
isFavorite: [],
isImage: [],
isTrashed: [],
livePhotoVideoId: [],
localDateTime: [],
ownerId: [],
projectionType: [],
ratio: [],
stack: [],
thumbhash: [],
};
for (const asset of timelineAsset) {
bucketAssets.city.push(asset.city);
bucketAssets.country.push(asset.country);
bucketAssets.duration.push(asset.duration!);
bucketAssets.id.push(asset.id);
bucketAssets.visibility.push(asset.visibility);
bucketAssets.isFavorite.push(asset.isFavorite);
bucketAssets.isImage.push(asset.isImage);
bucketAssets.isTrashed.push(asset.isTrashed);
bucketAssets.livePhotoVideoId.push(asset.livePhotoVideoId!);
bucketAssets.localDateTime.push(asset.localDateTime);
bucketAssets.ownerId.push(asset.ownerId);
bucketAssets.projectionType.push(asset.projectionType!);
bucketAssets.ratio.push(asset.ratio);
bucketAssets.stack?.push(asset.stack ? [asset.stack.id, asset.stack.assetCount.toString()] : null);
bucketAssets.thumbhash.push(asset.thumbhash!);
}
return bucketAssets;
};