Compare commits

...

54 Commits

Author SHA1 Message Date
mertalev
12381f6b3c use Date 2025-05-06 21:34:59 -04:00
mertalev
c9728a107e rename patch 2025-05-06 18:08:32 -04:00
mertalev
35d91aa6bf silly generator 2025-05-06 18:06:59 -04:00
mertalev
4174575785 fix 2025-05-06 14:55:00 -04:00
mertalev
606d4b66d0 fix spec 2025-05-05 11:16:13 -04:00
mertalev
71cc045405 string tuple 2025-05-05 10:35:12 -04:00
mertalev
21bbf2f5e2 linting, fix expected response 2025-05-05 10:03:20 -04:00
mertalev
3ace02b3e7 update timeline tests 2025-05-05 09:54:40 -04:00
mertalev
85359bfc1a update sql 2025-05-05 09:43:51 -04:00
mertalev
f7712c332e update sql 2025-05-05 09:38:24 -04:00
mertalev
b20440e4d5 update alt text tests 2025-05-05 09:18:23 -04:00
mertalev
1d885c1a20 update references to description 2025-05-05 09:12:07 -04:00
mertalev
ef9245487c openapi 2025-05-04 20:12:40 -04:00
mertalev
a3a2ced3a9 stack as tuple 2025-05-04 20:11:48 -04:00
mertalev
8837f5b4fb openapi 2025-05-04 19:26:09 -04:00
mertalev
97cc9e223e push aggregation to query 2025-05-04 19:24:08 -04:00
Min Idzelis
07c03b8a79 test 2025-05-03 14:24:00 +00:00
Min Idzelis
5a3e32fc3c lint 2025-05-03 14:07:39 +00:00
Min Idzelis
5520db10af lint/tests 2025-05-03 13:58:46 +00:00
Min Idzelis
ee08fd012d tests 2025-05-03 13:47:21 +00:00
Min Idzelis
f7fd213260 tests 2025-05-03 13:45:13 +00:00
Min Idzelis
73cd236756 date->string 2025-05-03 13:33:58 +00:00
Min Idzelis
bf0be6a655 openapi battle 2025-05-03 02:43:06 +00:00
Min Idzelis
6e8993c6eb Merge branch 'lighter_buckets_web' into lighter_buckets_server 2025-05-03 02:08:15 +00:00
Min Idzelis
aea2c9506d Use nulls, make-sql 2025-05-03 02:06:34 +00:00
Min Idzelis
8011605e6f lint 2025-05-02 23:29:14 +00:00
Min Idzelis
0ed2a2fd2e Merge remote-tracking branch 'origin/lighter_buckets_web' into lighter_buckets_server 2025-05-02 23:24:34 +00:00
Min Idzelis
9d527b37f0 Merge branch 'main' into lighter_buckets_web 2025-05-02 19:19:46 -04:00
Min Idzelis
1d3a546646 missing import 2025-05-02 01:23:40 +00:00
Min Idzelis
c16348e3fd Merge remote-tracking branch 'origin/main' into lighter_buckets_web 2025-05-02 00:56:41 +00:00
Min Idzelis
15d5460afb test 2025-04-29 13:52:00 +00:00
Min Idzelis
bc5d4b45a6 Adapt web client to consume new server response format 2025-04-29 13:45:40 +00:00
Min Idzelis
077703adcc Merge branch 'lighter_buckets_web' into lighter_buckets_server 2025-04-29 02:00:43 +00:00
Min Idzelis
580a0117c4 fix after merge 2025-04-29 01:50:39 +00:00
Min Idzelis
ffda7364dd Merge remote-tracking branch 'origin/main' into lighter_buckets_web 2025-04-29 01:35:20 +00:00
Min Idzelis
236973e329 unneeded cast 2025-04-29 01:26:53 +00:00
Min Idzelis
cd8806eac0 revert settings 2025-04-28 13:04:00 +00:00
Min Idzelis
7f934583cf lint 2025-04-24 02:07:45 +00:00
Min Idzelis
6308ae71a1 fix: flappy e2e test 2025-04-24 02:01:27 +00:00
Min Idzelis
bfefa36f04 feat(server): lighter buckets 2025-04-24 01:51:16 +00:00
Min Idzelis
50cfc461a9 missing import 2025-04-24 00:20:08 +00:00
Min Idzelis
77121a0e07 tests 2025-04-24 00:09:11 +00:00
Min Idzelis
89bfa692b1 update tests 2025-04-23 23:40:00 +00:00
Min Idzelis
683a10f0fe Merge branch 'main' into lighter_buckets_web 2025-04-23 19:20:27 -04:00
Min Idzelis
a5eaaddec4 test fix 2025-04-23 23:19:48 +00:00
Min Idzelis
d76c50ff22 Merge remote-tracking branch 'origin/main' into lighter_buckets_web 2025-04-23 21:41:20 +00:00
Min Idzelis
0795f8a761 re-add alt-text 2025-04-23 21:41:09 +00:00
Min Idzelis
6cb7fffe91 empty - trigger ci 2025-04-20 14:08:52 +00:00
Min Idzelis
9f6120a134 ensure keys on getAssetInfo, alt-text 2025-04-20 12:51:26 +00:00
Min Idzelis
f3fe043c22 Remove generics from AssetInteraction 2025-04-20 03:47:51 +00:00
Min Idzelis
9b7e9bc7b8 weird ssr 2025-04-20 03:27:18 +00:00
Min Idzelis
c1e699ebaf GalleryViewer 2025-04-20 02:51:32 +00:00
Min Idzelis
3b9490e28d Merge remote-tracking branch 'origin/main' into lighter_buckets_web 2025-04-19 22:46:41 +00:00
Min Idzelis
5a8f9f3b5c feat(web): lighter timeline buckets 2025-04-19 22:43:08 +00:00
86 changed files with 2261 additions and 1003 deletions

194
api.mustache Normal file
View File

@@ -0,0 +1,194 @@
{{>header}}
{{>part_of}}
{{#operations}}
class {{{classname}}} {
{{{classname}}}([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
final ApiClient apiClient;
{{#operation}}
{{#summary}}
/// {{{.}}}
{{/summary}}
{{#notes}}
{{#summary}}
///
{{/summary}}
/// {{{notes}}}
///
/// Note: This method returns the HTTP [Response].
{{/notes}}
{{^notes}}
{{#summary}}
///
/// Note: This method returns the HTTP [Response].
{{/summary}}
{{^summary}}
/// Performs an HTTP '{{{httpMethod}}} {{{path}}}' operation and returns the [Response].
{{/summary}}
{{/notes}}
{{#hasParams}}
{{#summary}}
///
{{/summary}}
{{^summary}}
{{#notes}}
///
{{/notes}}
{{/summary}}
/// Parameters:
///
{{/hasParams}}
{{#allParams}}
/// * [{{{dataType}}}] {{{paramName}}}{{#required}} (required){{/required}}{{#optional}} (optional){{/optional}}:
{{#description}}
/// {{{.}}}
{{/description}}
{{^-last}}
///
{{/-last}}
{{/allParams}}
Future<Response> {{{nickname}}}WithHttpInfo({{#allParams}}{{#required}}{{{dataType}}} {{{paramName}}},{{^-last}} {{/-last}}{{/required}}{{/allParams}}{{#hasOptionalParams}}{ {{#allParams}}{{^required}}{{{dataType}}}? {{{paramName}}},{{^-last}} {{/-last}}{{/required}}{{/allParams}} }{{/hasOptionalParams}}) async {
// ignore: prefer_const_declarations
final path = r'{{{path}}}'{{#pathParams}}
.replaceAll({{=<% %>=}}'{<% baseName %>}'<%={{ }}=%>, {{{paramName}}}{{^isString}}.toString(){{/isString}}){{/pathParams}};
// ignore: prefer_final_locals
Object? postBody{{#bodyParam}} = {{{paramName}}}{{/bodyParam}};
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
{{#hasQueryParams}}
{{#queryParams}}
{{^required}}
if ({{{paramName}}} != null) {
{{/required}}
queryParams.addAll(_queryParams('{{{collectionFormat}}}', '{{{baseName}}}', {{{paramName}}}));
{{^required}}
}
{{/required}}
{{/queryParams}}
{{/hasQueryParams}}
{{#hasHeaderParams}}
{{#headerParams}}
{{#required}}
headerParams[r'{{{baseName}}}'] = parameterToString({{{paramName}}});
{{/required}}
{{^required}}
if ({{{paramName}}} != null) {
headerParams[r'{{{baseName}}}'] = parameterToString({{{paramName}}});
}
{{/required}}
{{/headerParams}}
{{/hasHeaderParams}}
const contentTypes = <String>[{{#prioritizedContentTypes}}'{{{mediaType}}}'{{^-last}}, {{/-last}}{{/prioritizedContentTypes}}];
{{#isMultipart}}
bool hasFields = false;
final mp = MultipartRequest('{{{httpMethod}}}', Uri.parse(path));
{{#formParams}}
{{^isFile}}
if ({{{paramName}}} != null) {
hasFields = true;
mp.fields[r'{{{baseName}}}'] = parameterToString({{{paramName}}});
}
{{/isFile}}
{{#isFile}}
if ({{{paramName}}} != null) {
hasFields = true;
mp.fields[r'{{{baseName}}}'] = {{{paramName}}}.field;
mp.files.add({{{paramName}}});
}
{{/isFile}}
{{/formParams}}
if (hasFields) {
postBody = mp;
}
{{/isMultipart}}
{{^isMultipart}}
{{#formParams}}
{{^isFile}}
if ({{{paramName}}} != null) {
formParams[r'{{{baseName}}}'] = parameterToString({{{paramName}}});
}
{{/isFile}}
{{/formParams}}
{{/isMultipart}}
return apiClient.invokeAPI(
path,
'{{{httpMethod}}}',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
{{#summary}}
/// {{{.}}}
{{/summary}}
{{#notes}}
{{#summary}}
///
{{/summary}}
/// {{{notes}}}
{{/notes}}
{{#hasParams}}
{{#summary}}
///
{{/summary}}
{{^summary}}
{{#notes}}
///
{{/notes}}
{{/summary}}
/// Parameters:
///
{{/hasParams}}
{{#allParams}}
/// * [{{{dataType}}}] {{{paramName}}}{{#required}} (required){{/required}}{{#optional}} (optional){{/optional}}:
{{#description}}
/// {{{.}}}
{{/description}}
{{^-last}}
///
{{/-last}}
{{/allParams}}
Future<{{#returnType}}{{{.}}}?{{/returnType}}{{^returnType}}void{{/returnType}}> {{{nickname}}}({{#allParams}}{{#required}}{{{dataType}}} {{{paramName}}},{{^-last}} {{/-last}}{{/required}}{{/allParams}}{{#hasOptionalParams}}{ {{#allParams}}{{^required}}{{{dataType}}}? {{{paramName}}},{{^-last}} {{/-last}}{{/required}}{{/allParams}} }{{/hasOptionalParams}}) async {
final response = await {{{nickname}}}WithHttpInfo({{#allParams}}{{#required}}{{{paramName}}},{{^-last}} {{/-last}}{{/required}}{{/allParams}}{{#hasOptionalParams}} {{#allParams}}{{^required}}{{{paramName}}}: {{{paramName}}},{{^-last}} {{/-last}}{{/required}}{{/allParams}} {{/hasOptionalParams}});
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
{{#returnType}}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
{{#native_serialization}}
{{#isArray}}
final responseBody = await _decodeBodyBytes(response);
return (await apiClient.deserializeAsync(responseBody, '{{{returnType}}}') as List)
.cast<{{{returnBaseType}}}>()
.{{#uniqueItems}}toSet(){{/uniqueItems}}{{^uniqueItems}}toList(growable: false){{/uniqueItems}};
{{/isArray}}
{{^isArray}}
{{#isMap}}
return {{{returnType}}}.from(await apiClient.deserializeAsync(await _decodeBodyBytes(response), '{{{returnType}}}'),);
{{/isMap}}
{{^isMap}}
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), '{{{returnType}}}',) as {{{returnType}}};
{{/isMap}}{{/isArray}}{{/native_serialization}}
}
return null;
{{/returnType}}
}
{{/operation}}
}
{{/operations}}

View File

@@ -1,4 +1,4 @@
import { AssetMediaResponseDto, LoginResponseDto, SharedLinkType, TimeBucketSize } from '@immich/sdk'; import { AssetMediaResponseDto, 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').query({});
expect(status).toBe(401); expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized); expect(body).toEqual(errorDto.unauthorized);
}); });
@@ -61,7 +61,7 @@ describe('/timeline', () => {
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 }); .query({});
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toEqual( expect(body).toEqual(
@@ -78,33 +78,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, isArchived: true }); .query({ withPartners: true, isArchived: true });
expect(req1.status).toBe(400); expect(req1.status).toBe(400);
expect(req1.body).toEqual(errorDto.badRequest()); expect(req1.body).toEqual(errorDto.badRequest());
@@ -112,7 +96,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, isArchived: undefined }); .query({ withPartners: true, isArchived: 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 +106,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 +114,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 +124,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 +134,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 +144,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: [],
isArchived: [],
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 +172,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 +182,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: [],
isArchived: [],
isFavorite: [],
isImage: [],
isTrashed: [],
livePhotoVideoId: [],
localDateTime: [],
ownerId: [],
projectionType: [],
ratio: [],
status: [],
thumbhash: [],
});
}); });
}); });
}); });

View File

@@ -477,8 +477,8 @@ Class | Method | HTTP request | Description
- [TemplateDto](doc//TemplateDto.md) - [TemplateDto](doc//TemplateDto.md)
- [TemplateResponseDto](doc//TemplateResponseDto.md) - [TemplateResponseDto](doc//TemplateResponseDto.md)
- [TestEmailResponseDto](doc//TestEmailResponseDto.md) - [TestEmailResponseDto](doc//TestEmailResponseDto.md)
- [TimeBucketResponseDto](doc//TimeBucketResponseDto.md) - [TimeBucketAssetResponseDto](doc//TimeBucketAssetResponseDto.md)
- [TimeBucketSize](doc//TimeBucketSize.md) - [TimeBucketsResponseDto](doc//TimeBucketsResponseDto.md)
- [ToneMapping](doc//ToneMapping.md) - [ToneMapping](doc//ToneMapping.md)
- [TranscodeHWAccel](doc//TranscodeHWAccel.md) - [TranscodeHWAccel](doc//TranscodeHWAccel.md)
- [TranscodePolicy](doc//TranscodePolicy.md) - [TranscodePolicy](doc//TranscodePolicy.md)

View File

@@ -281,8 +281,8 @@ part 'model/tags_update.dart';
part 'model/template_dto.dart'; part 'model/template_dto.dart';
part 'model/template_response_dto.dart'; part 'model/template_response_dto.dart';
part 'model/test_email_response_dto.dart'; part 'model/test_email_response_dto.dart';
part 'model/time_bucket_response_dto.dart'; part 'model/time_bucket_asset_response_dto.dart';
part 'model/time_bucket_size.dart'; part 'model/time_buckets_response_dto.dart';
part 'model/tone_mapping.dart'; part 'model/tone_mapping.dart';
part 'model/transcode_hw_accel.dart'; part 'model/transcode_hw_accel.dart';
part 'model/transcode_policy.dart'; part 'model/transcode_policy.dart';

View File

@@ -19,8 +19,6 @@ class TimelineApi {
/// Performs an HTTP 'GET /timeline/bucket' operation and returns the [Response]. /// Performs an HTTP 'GET /timeline/bucket' operation and returns the [Response].
/// Parameters: /// Parameters:
/// ///
/// * [TimeBucketSize] size (required):
///
/// * [String] timeBucket (required): /// * [String] timeBucket (required):
/// ///
/// * [String] albumId: /// * [String] albumId:
@@ -35,6 +33,10 @@ class TimelineApi {
/// ///
/// * [AssetOrder] order: /// * [AssetOrder] order:
/// ///
/// * [num] page:
///
/// * [num] pageSize:
///
/// * [String] personId: /// * [String] personId:
/// ///
/// * [String] tagId: /// * [String] tagId:
@@ -44,7 +46,7 @@ class TimelineApi {
/// * [bool] withPartners: /// * [bool] withPartners:
/// ///
/// * [bool] withStacked: /// * [bool] withStacked:
Future<Response> getTimeBucketWithHttpInfo(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, }) async { Future<Response> getTimeBucketWithHttpInfo(String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, num? page, num? pageSize, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, }) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final apiPath = r'/timeline/bucket'; final apiPath = r'/timeline/bucket';
@@ -73,10 +75,15 @@ class TimelineApi {
if (order != null) { if (order != null) {
queryParams.addAll(_queryParams('', 'order', order)); queryParams.addAll(_queryParams('', 'order', order));
} }
if (page != null) {
queryParams.addAll(_queryParams('', 'page', page));
}
if (pageSize != null) {
queryParams.addAll(_queryParams('', 'pageSize', pageSize));
}
if (personId != null) { if (personId != null) {
queryParams.addAll(_queryParams('', 'personId', personId)); queryParams.addAll(_queryParams('', 'personId', personId));
} }
queryParams.addAll(_queryParams('', 'size', size));
if (tagId != null) { if (tagId != null) {
queryParams.addAll(_queryParams('', 'tagId', tagId)); queryParams.addAll(_queryParams('', 'tagId', tagId));
} }
@@ -107,8 +114,6 @@ class TimelineApi {
/// Parameters: /// Parameters:
/// ///
/// * [TimeBucketSize] size (required):
///
/// * [String] timeBucket (required): /// * [String] timeBucket (required):
/// ///
/// * [String] albumId: /// * [String] albumId:
@@ -123,6 +128,10 @@ class TimelineApi {
/// ///
/// * [AssetOrder] order: /// * [AssetOrder] order:
/// ///
/// * [num] page:
///
/// * [num] pageSize:
///
/// * [String] personId: /// * [String] personId:
/// ///
/// * [String] tagId: /// * [String] tagId:
@@ -132,8 +141,8 @@ class TimelineApi {
/// * [bool] withPartners: /// * [bool] withPartners:
/// ///
/// * [bool] withStacked: /// * [bool] withStacked:
Future<List<AssetResponseDto>?> getTimeBucket(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, }) async { Future<TimeBucketAssetResponseDto?> getTimeBucket(String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, num? page, num? pageSize, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, }) async {
final response = await getTimeBucketWithHttpInfo(size, timeBucket, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, tagId: tagId, userId: userId, withPartners: withPartners, withStacked: withStacked, ); final response = await getTimeBucketWithHttpInfo(timeBucket, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, page: page, pageSize: pageSize, personId: personId, tagId: tagId, userId: userId, withPartners: withPartners, withStacked: withStacked, );
if (response.statusCode >= HttpStatus.badRequest) { if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); throw ApiException(response.statusCode, await _decodeBodyBytes(response));
} }
@@ -141,10 +150,7 @@ class TimelineApi {
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input" // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string. // FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
final responseBody = await _decodeBodyBytes(response); return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'TimeBucketAssetResponseDto',) as TimeBucketAssetResponseDto;
return (await apiClient.deserializeAsync(responseBody, 'List<AssetResponseDto>') as List)
.cast<AssetResponseDto>()
.toList(growable: false);
} }
return null; return null;
@@ -153,8 +159,6 @@ class TimelineApi {
/// Performs an HTTP 'GET /timeline/buckets' operation and returns the [Response]. /// Performs an HTTP 'GET /timeline/buckets' operation and returns the [Response].
/// Parameters: /// Parameters:
/// ///
/// * [TimeBucketSize] size (required):
///
/// * [String] albumId: /// * [String] albumId:
/// ///
/// * [bool] isArchived: /// * [bool] isArchived:
@@ -176,7 +180,7 @@ class TimelineApi {
/// * [bool] withPartners: /// * [bool] withPartners:
/// ///
/// * [bool] withStacked: /// * [bool] withStacked:
Future<Response> getTimeBucketsWithHttpInfo(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, }) async { Future<Response> getTimeBucketsWithHttpInfo({ String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, }) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final apiPath = r'/timeline/buckets'; final apiPath = r'/timeline/buckets';
@@ -208,7 +212,6 @@ class TimelineApi {
if (personId != null) { if (personId != null) {
queryParams.addAll(_queryParams('', 'personId', personId)); queryParams.addAll(_queryParams('', 'personId', personId));
} }
queryParams.addAll(_queryParams('', 'size', size));
if (tagId != null) { if (tagId != null) {
queryParams.addAll(_queryParams('', 'tagId', tagId)); queryParams.addAll(_queryParams('', 'tagId', tagId));
} }
@@ -238,8 +241,6 @@ class TimelineApi {
/// Parameters: /// Parameters:
/// ///
/// * [TimeBucketSize] size (required):
///
/// * [String] albumId: /// * [String] albumId:
/// ///
/// * [bool] isArchived: /// * [bool] isArchived:
@@ -261,8 +262,8 @@ class TimelineApi {
/// * [bool] withPartners: /// * [bool] withPartners:
/// ///
/// * [bool] withStacked: /// * [bool] withStacked:
Future<List<TimeBucketResponseDto>?> getTimeBuckets(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, }) async { Future<List<TimeBucketsResponseDto>?> getTimeBuckets({ String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, }) async {
final response = await getTimeBucketsWithHttpInfo(size, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, tagId: tagId, userId: userId, withPartners: withPartners, withStacked: withStacked, ); final response = await getTimeBucketsWithHttpInfo( albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, tagId: tagId, userId: userId, withPartners: withPartners, withStacked: withStacked, );
if (response.statusCode >= HttpStatus.badRequest) { if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); throw ApiException(response.statusCode, await _decodeBodyBytes(response));
} }
@@ -271,8 +272,8 @@ class TimelineApi {
// FormatException when trying to decode an empty string. // FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
final responseBody = await _decodeBodyBytes(response); final responseBody = await _decodeBodyBytes(response);
return (await apiClient.deserializeAsync(responseBody, 'List<TimeBucketResponseDto>') as List) return (await apiClient.deserializeAsync(responseBody, 'List<TimeBucketsResponseDto>') as List)
.cast<TimeBucketResponseDto>() .cast<TimeBucketsResponseDto>()
.toList(growable: false); .toList(growable: false);
} }

View File

@@ -618,10 +618,10 @@ class ApiClient {
return TemplateResponseDto.fromJson(value); return TemplateResponseDto.fromJson(value);
case 'TestEmailResponseDto': case 'TestEmailResponseDto':
return TestEmailResponseDto.fromJson(value); return TestEmailResponseDto.fromJson(value);
case 'TimeBucketResponseDto': case 'TimeBucketAssetResponseDto':
return TimeBucketResponseDto.fromJson(value); return TimeBucketAssetResponseDto.fromJson(value);
case 'TimeBucketSize': case 'TimeBucketsResponseDto':
return TimeBucketSizeTypeTransformer().decode(value); return TimeBucketsResponseDto.fromJson(value);
case 'ToneMapping': case 'ToneMapping':
return ToneMappingTypeTransformer().decode(value); return ToneMappingTypeTransformer().decode(value);
case 'TranscodeHWAccel': case 'TranscodeHWAccel':

View File

@@ -136,9 +136,6 @@ String parameterToString(dynamic value) {
if (value is SyncRequestType) { if (value is SyncRequestType) {
return SyncRequestTypeTypeTransformer().encode(value).toString(); return SyncRequestTypeTypeTransformer().encode(value).toString();
} }
if (value is TimeBucketSize) {
return TimeBucketSizeTypeTransformer().encode(value).toString();
}
if (value is ToneMapping) { if (value is ToneMapping) {
return ToneMappingTypeTransformer().encode(value).toString(); return ToneMappingTypeTransformer().encode(value).toString();
} }

View File

@@ -0,0 +1,243 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class TimeBucketAssetResponseDto {
/// Returns a new [TimeBucketAssetResponseDto] instance.
TimeBucketAssetResponseDto({
this.city = const [],
this.country = const [],
this.duration = const [],
this.id = const [],
this.isArchived = const [],
this.isFavorite = const [],
this.isImage = const [],
this.isTrashed = const [],
this.livePhotoVideoId = const [],
this.localDateTime = const [],
this.ownerId = const [],
this.projectionType = const [],
this.ratio = const [],
this.stack = const [],
this.thumbhash = const [],
});
List<String?> city;
List<String?> country;
List<String?> duration;
List<String> id;
List<num> isArchived;
List<num> isFavorite;
List<num> isImage;
List<num> isTrashed;
List<String?> livePhotoVideoId;
List<String> localDateTime;
List<String> ownerId;
List<String?> projectionType;
List<num> ratio;
/// (stack ID, stack asset count) tuple
List<List<String>?> stack;
List<String?> thumbhash;
@override
bool operator ==(Object other) => identical(this, other) || other is TimeBucketAssetResponseDto &&
_deepEquality.equals(other.city, city) &&
_deepEquality.equals(other.country, country) &&
_deepEquality.equals(other.duration, duration) &&
_deepEquality.equals(other.id, id) &&
_deepEquality.equals(other.isArchived, isArchived) &&
_deepEquality.equals(other.isFavorite, isFavorite) &&
_deepEquality.equals(other.isImage, isImage) &&
_deepEquality.equals(other.isTrashed, isTrashed) &&
_deepEquality.equals(other.livePhotoVideoId, livePhotoVideoId) &&
_deepEquality.equals(other.localDateTime, localDateTime) &&
_deepEquality.equals(other.ownerId, ownerId) &&
_deepEquality.equals(other.projectionType, projectionType) &&
_deepEquality.equals(other.ratio, ratio) &&
_deepEquality.equals(other.stack, stack) &&
_deepEquality.equals(other.thumbhash, thumbhash);
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(city.hashCode) +
(country.hashCode) +
(duration.hashCode) +
(id.hashCode) +
(isArchived.hashCode) +
(isFavorite.hashCode) +
(isImage.hashCode) +
(isTrashed.hashCode) +
(livePhotoVideoId.hashCode) +
(localDateTime.hashCode) +
(ownerId.hashCode) +
(projectionType.hashCode) +
(ratio.hashCode) +
(stack.hashCode) +
(thumbhash.hashCode);
@override
String toString() => 'TimeBucketAssetResponseDto[city=$city, country=$country, duration=$duration, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isImage=$isImage, isTrashed=$isTrashed, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, ownerId=$ownerId, projectionType=$projectionType, ratio=$ratio, stack=$stack, thumbhash=$thumbhash]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'city'] = this.city;
json[r'country'] = this.country;
json[r'duration'] = this.duration;
json[r'id'] = this.id;
json[r'isArchived'] = this.isArchived;
json[r'isFavorite'] = this.isFavorite;
json[r'isImage'] = this.isImage;
json[r'isTrashed'] = this.isTrashed;
json[r'livePhotoVideoId'] = this.livePhotoVideoId;
json[r'localDateTime'] = this.localDateTime;
json[r'ownerId'] = this.ownerId;
json[r'projectionType'] = this.projectionType;
json[r'ratio'] = this.ratio;
json[r'stack'] = this.stack;
json[r'thumbhash'] = this.thumbhash;
return json;
}
/// Returns a new [TimeBucketAssetResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static TimeBucketAssetResponseDto? fromJson(dynamic value) {
upgradeDto(value, "TimeBucketAssetResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return TimeBucketAssetResponseDto(
city: json[r'city'] is Iterable
? (json[r'city'] as Iterable).cast<String>().toList(growable: false)
: const [],
country: json[r'country'] is Iterable
? (json[r'country'] as Iterable).cast<String>().toList(growable: false)
: const [],
duration: json[r'duration'] is Iterable
? (json[r'duration'] as Iterable).cast<String>().toList(growable: false)
: const [],
id: json[r'id'] is Iterable
? (json[r'id'] as Iterable).cast<String>().toList(growable: false)
: const [],
isArchived: json[r'isArchived'] is Iterable
? (json[r'isArchived'] as Iterable).cast<num>().toList(growable: false)
: const [],
isFavorite: json[r'isFavorite'] is Iterable
? (json[r'isFavorite'] as Iterable).cast<num>().toList(growable: false)
: const [],
isImage: json[r'isImage'] is Iterable
? (json[r'isImage'] as Iterable).cast<num>().toList(growable: false)
: const [],
isTrashed: json[r'isTrashed'] is Iterable
? (json[r'isTrashed'] as Iterable).cast<num>().toList(growable: false)
: const [],
livePhotoVideoId: json[r'livePhotoVideoId'] is Iterable
? (json[r'livePhotoVideoId'] as Iterable).cast<String>().toList(growable: false)
: const [],
localDateTime: json[r'localDateTime'] is Iterable
? (json[r'localDateTime'] as Iterable).cast<String>().toList(growable: false)
: const [],
ownerId: json[r'ownerId'] is Iterable
? (json[r'ownerId'] as Iterable).cast<String>().toList(growable: false)
: const [],
projectionType: json[r'projectionType'] is Iterable
? (json[r'projectionType'] as Iterable).cast<String>().toList(growable: false)
: const [],
ratio: json[r'ratio'] is Iterable
? (json[r'ratio'] as Iterable).cast<num>().toList(growable: false)
: const [],
stack: json[r'stack'] is List
? (json[r'stack'] as List).map((e) =>
e == null ? null : (e as List).cast<String>()
).toList()
: const [],
thumbhash: json[r'thumbhash'] is Iterable
? (json[r'thumbhash'] as Iterable).cast<String>().toList(growable: false)
: const [],
);
}
return null;
}
static List<TimeBucketAssetResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <TimeBucketAssetResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = TimeBucketAssetResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, TimeBucketAssetResponseDto> mapFromJson(dynamic json) {
final map = <String, TimeBucketAssetResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = TimeBucketAssetResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of TimeBucketAssetResponseDto-objects as value to a dart map
static Map<String, List<TimeBucketAssetResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<TimeBucketAssetResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = TimeBucketAssetResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'city',
'country',
'duration',
'id',
'isArchived',
'isFavorite',
'isImage',
'isTrashed',
'livePhotoVideoId',
'localDateTime',
'ownerId',
'projectionType',
'ratio',
'thumbhash',
};
}

View File

@@ -1,85 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class TimeBucketSize {
/// Instantiate a new enum with the provided [value].
const TimeBucketSize._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const DAY = TimeBucketSize._(r'DAY');
static const MONTH = TimeBucketSize._(r'MONTH');
/// List of all possible values in this [enum][TimeBucketSize].
static const values = <TimeBucketSize>[
DAY,
MONTH,
];
static TimeBucketSize? fromJson(dynamic value) => TimeBucketSizeTypeTransformer().decode(value);
static List<TimeBucketSize> listFromJson(dynamic json, {bool growable = false,}) {
final result = <TimeBucketSize>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = TimeBucketSize.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [TimeBucketSize] to String,
/// and [decode] dynamic data back to [TimeBucketSize].
class TimeBucketSizeTypeTransformer {
factory TimeBucketSizeTypeTransformer() => _instance ??= const TimeBucketSizeTypeTransformer._();
const TimeBucketSizeTypeTransformer._();
String encode(TimeBucketSize data) => data.value;
/// Decodes a [dynamic value][data] to a TimeBucketSize.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
TimeBucketSize? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'DAY': return TimeBucketSize.DAY;
case r'MONTH': return TimeBucketSize.MONTH;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [TimeBucketSizeTypeTransformer] instance.
static TimeBucketSizeTypeTransformer? _instance;
}

View File

@@ -10,9 +10,9 @@
part of openapi.api; part of openapi.api;
class TimeBucketResponseDto { class TimeBucketsResponseDto {
/// Returns a new [TimeBucketResponseDto] instance. /// Returns a new [TimeBucketsResponseDto] instance.
TimeBucketResponseDto({ TimeBucketsResponseDto({
required this.count, required this.count,
required this.timeBucket, required this.timeBucket,
}); });
@@ -22,7 +22,7 @@ class TimeBucketResponseDto {
String timeBucket; String timeBucket;
@override @override
bool operator ==(Object other) => identical(this, other) || other is TimeBucketResponseDto && bool operator ==(Object other) => identical(this, other) || other is TimeBucketsResponseDto &&
other.count == count && other.count == count &&
other.timeBucket == timeBucket; other.timeBucket == timeBucket;
@@ -33,7 +33,7 @@ class TimeBucketResponseDto {
(timeBucket.hashCode); (timeBucket.hashCode);
@override @override
String toString() => 'TimeBucketResponseDto[count=$count, timeBucket=$timeBucket]'; String toString() => 'TimeBucketsResponseDto[count=$count, timeBucket=$timeBucket]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@@ -42,15 +42,15 @@ class TimeBucketResponseDto {
return json; return json;
} }
/// Returns a new [TimeBucketResponseDto] instance and imports its values from /// Returns a new [TimeBucketsResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise. /// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods // ignore: prefer_constructors_over_static_methods
static TimeBucketResponseDto? fromJson(dynamic value) { static TimeBucketsResponseDto? fromJson(dynamic value) {
upgradeDto(value, "TimeBucketResponseDto"); upgradeDto(value, "TimeBucketsResponseDto");
if (value is Map) { if (value is Map) {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();
return TimeBucketResponseDto( return TimeBucketsResponseDto(
count: mapValueOfType<int>(json, r'count')!, count: mapValueOfType<int>(json, r'count')!,
timeBucket: mapValueOfType<String>(json, r'timeBucket')!, timeBucket: mapValueOfType<String>(json, r'timeBucket')!,
); );
@@ -58,11 +58,11 @@ class TimeBucketResponseDto {
return null; return null;
} }
static List<TimeBucketResponseDto> listFromJson(dynamic json, {bool growable = false,}) { static List<TimeBucketsResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <TimeBucketResponseDto>[]; final result = <TimeBucketsResponseDto>[];
if (json is List && json.isNotEmpty) { if (json is List && json.isNotEmpty) {
for (final row in json) { for (final row in json) {
final value = TimeBucketResponseDto.fromJson(row); final value = TimeBucketsResponseDto.fromJson(row);
if (value != null) { if (value != null) {
result.add(value); result.add(value);
} }
@@ -71,12 +71,12 @@ class TimeBucketResponseDto {
return result.toList(growable: growable); return result.toList(growable: growable);
} }
static Map<String, TimeBucketResponseDto> mapFromJson(dynamic json) { static Map<String, TimeBucketsResponseDto> mapFromJson(dynamic json) {
final map = <String, TimeBucketResponseDto>{}; final map = <String, TimeBucketsResponseDto>{};
if (json is Map && json.isNotEmpty) { if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) { for (final entry in json.entries) {
final value = TimeBucketResponseDto.fromJson(entry.value); final value = TimeBucketsResponseDto.fromJson(entry.value);
if (value != null) { if (value != null) {
map[entry.key] = value; map[entry.key] = value;
} }
@@ -85,14 +85,14 @@ class TimeBucketResponseDto {
return map; return map;
} }
// maps a json object with a list of TimeBucketResponseDto-objects as value to a dart map // maps a json object with a list of TimeBucketsResponseDto-objects as value to a dart map
static Map<String, List<TimeBucketResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) { static Map<String, List<TimeBucketsResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<TimeBucketResponseDto>>{}; final map = <String, List<TimeBucketsResponseDto>>{};
if (json is Map && json.isNotEmpty) { if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments // ignore: parameter_assignments
json = json.cast<String, dynamic>(); json = json.cast<String, dynamic>();
for (final entry in json.entries) { for (final entry in json.entries) {
map[entry.key] = TimeBucketResponseDto.listFromJson(entry.value, growable: growable,); map[entry.key] = TimeBucketsResponseDto.listFromJson(entry.value, growable: growable,);
} }
} }
return map; return map;

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

@@ -0,0 +1,301 @@
class {{{classname}}} {
{{>dart_constructor}}
{{#vars}}
{{#description}}
/// {{{.}}}
{{/description}}
{{^isEnum}}
{{#minimum}}
{{#description}}
///
{{/description}}
/// Minimum value: {{{.}}}
{{/minimum}}
{{#maximum}}
{{#description}}
{{^minimum}}
///
{{/minimum}}
{{/description}}
/// Maximum value: {{{.}}}
{{/maximum}}
{{^isNullable}}
{{^required}}
{{^defaultValue}}
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
{{/defaultValue}}
{{/required}}
{{/isNullable}}
{{/isEnum}}
{{{datatypeWithEnum}}}{{#isNullable}}?{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}?{{/defaultValue}}{{/required}}{{/isNullable}} {{{name}}};
{{/vars}}
@override
bool operator ==(Object other) => identical(this, other) || other is {{{classname}}} &&
{{#vars}}
{{#isMap}}_deepEquality.equals(other.{{{name}}}, {{{name}}}){{/isMap}}{{^isMap}}{{#isArray}}_deepEquality.equals(other.{{{name}}}, {{{name}}}){{/isArray}}{{^isArray}}other.{{{name}}} == {{{name}}}{{/isArray}}{{/isMap}}{{^-last}} &&{{/-last}}{{#-last}};{{/-last}}
{{/vars}}
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
{{#vars}}
({{#isNullable}}{{{name}}} == null ? 0 : {{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}{{{name}}} == null ? 0 : {{/defaultValue}}{{/required}}{{/isNullable}}{{{name}}}{{#isNullable}}!{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}!{{/defaultValue}}{{/required}}{{/isNullable}}.hashCode){{^-last}} +{{/-last}}{{#-last}};{{/-last}}
{{/vars}}
@override
String toString() => '{{{classname}}}[{{#vars}}{{{name}}}=${{{name}}}{{^-last}}, {{/-last}}{{/vars}}]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
{{#vars}}
{{#isNullable}}
if (this.{{{name}}} != null) {
{{/isNullable}}
{{^isNullable}}
{{^required}}
{{^defaultValue}}
if (this.{{{name}}} != null) {
{{/defaultValue}}
{{/required}}
{{/isNullable}}
{{#isDateTime}}
{{#pattern}}
json[r'{{{baseName}}}'] = _isEpochMarker(r'{{{pattern}}}')
? this.{{{name}}}{{#isNullable}}!{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}!{{/defaultValue}}{{/required}}{{/isNullable}}.millisecondsSinceEpoch
: this.{{{name}}}{{#isNullable}}!{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}!{{/defaultValue}}{{/required}}{{/isNullable}}.toUtc().toIso8601String();
{{/pattern}}
{{^pattern}}
json[r'{{{baseName}}}'] = this.{{{name}}}{{#isNullable}}!{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}!{{/defaultValue}}{{/required}}{{/isNullable}}.toUtc().toIso8601String();
{{/pattern}}
{{/isDateTime}}
{{#isDate}}
{{#pattern}}
json[r'{{{baseName}}}'] = _isEpochMarker(r'{{{pattern}}}')
? this.{{{name}}}{{#isNullable}}!{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}!{{/defaultValue}}{{/required}}{{/isNullable}}.millisecondsSinceEpoch
: _dateFormatter.format(this.{{{name}}}{{#isNullable}}!{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}!{{/defaultValue}}{{/required}}{{/isNullable}}.toUtc());
{{/pattern}}
{{^pattern}}
json[r'{{{baseName}}}'] = _dateFormatter.format(this.{{{name}}}{{#isNullable}}!{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}!{{/defaultValue}}{{/required}}{{/isNullable}}.toUtc());
{{/pattern}}
{{/isDate}}
{{^isDateTime}}
{{^isDate}}
json[r'{{{baseName}}}'] = this.{{{name}}}{{#isArray}}{{#uniqueItems}}{{#isNullable}}!{{/isNullable}}.toList(growable: false){{/uniqueItems}}{{/isArray}};
{{/isDate}}
{{/isDateTime}}
{{#isNullable}}
} else {
json[r'{{{baseName}}}'] = null;
}
{{/isNullable}}
{{^isNullable}}
{{^required}}
{{^defaultValue}}
} else {
json[r'{{{baseName}}}'] = null;
}
{{/defaultValue}}
{{/required}}
{{/isNullable}}
{{/vars}}
return json;
}
/// Returns a new [{{{classname}}}] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static {{{classname}}}? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
// Ensure that the map contains the required keys.
// Note 1: the values aren't checked for validity beyond being non-null.
// Note 2: this code is stripped in release mode!
assert(() {
requiredKeys.forEach((key) {
assert(json.containsKey(key), 'Required key "{{{classname}}}[$key]" is missing from JSON.');
assert(json[key] != null, 'Required key "{{{classname}}}[$key]" has a null value in JSON.');
});
return true;
}());
return {{{classname}}}(
{{#vars}}
{{#isDateTime}}
{{{name}}}: mapDateTime(json, r'{{{baseName}}}', r'{{{pattern}}}'){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}},
{{/isDateTime}}
{{#isDate}}
{{{name}}}: mapDateTime(json, r'{{{baseName}}}', r'{{{pattern}}}'){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}},
{{/isDate}}
{{^isDateTime}}
{{^isDate}}
{{#complexType}}
{{#isArray}}
{{#items.isArray}}
{{{name}}}: json[r'{{{baseName}}}'] is List
? (json[r'{{{baseName}}}'] as List).map((e) =>
{{#items.complexType}}
{{items.complexType}}.listFromJson(json[r'{{{baseName}}}']){{#uniqueItems}}.toSet(){{/uniqueItems}}
{{/items.complexType}}
{{^items.complexType}}
e == null ? {{#items.isNullable}}null{{/items.isNullable}}{{^items.isNullable}}const <{{items.items.dataType}}>[]{{/items.isNullable}} : (e as List).cast<{{items.items.dataType}}>()
{{/items.complexType}}
).toList()
: {{#isNullable}}null{{/isNullable}}{{^isNullable}}const []{{/isNullable}},
{{/items.isArray}}
{{^items.isArray}}
{{{name}}}: {{{complexType}}}.listFromJson(json[r'{{{baseName}}}']){{#uniqueItems}}.toSet(){{/uniqueItems}},
{{/items.isArray}}
{{/isArray}}
{{^isArray}}
{{#isMap}}
{{#items.isArray}}
{{{name}}}: json[r'{{{baseName}}}'] == null
? {{#defaultValue}}{{{.}}}{{/defaultValue}}{{^defaultValue}}null{{/defaultValue}}
{{#items.complexType}}
: {{items.complexType}}.mapListFromJson(json[r'{{{baseName}}}']),
{{/items.complexType}}
{{^items.complexType}}
: mapCastOfType<String, List>(json, r'{{{baseName}}}'),
{{/items.complexType}}
{{/items.isArray}}
{{^items.isArray}}
{{#items.isMap}}
{{#items.complexType}}
{{{name}}}: {{items.complexType}}.mapFromJson(json[r'{{{baseName}}}']),
{{/items.complexType}}
{{^items.complexType}}
{{{name}}}: mapCastOfType<String, dynamic>(json, r'{{{baseName}}}'){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}},
{{/items.complexType}}
{{/items.isMap}}
{{^items.isMap}}
{{#items.complexType}}
{{{name}}}: {{{items.complexType}}}.mapFromJson(json[r'{{{baseName}}}']),
{{/items.complexType}}
{{^items.complexType}}
{{{name}}}: mapCastOfType<String, {{items.dataType}}>(json, r'{{{baseName}}}'){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}},
{{/items.complexType}}
{{/items.isMap}}
{{/items.isArray}}
{{/isMap}}
{{^isMap}}
{{#isBinary}}
{{{name}}}: null, // No support for decoding binary content from JSON
{{/isBinary}}
{{^isBinary}}
{{{name}}}: {{{complexType}}}.fromJson(json[r'{{{baseName}}}']){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}},
{{/isBinary}}
{{/isMap}}
{{/isArray}}
{{/complexType}}
{{^complexType}}
{{#isArray}}
{{#isEnum}}
{{{name}}}: {{{items.datatypeWithEnum}}}.listFromJson(json[r'{{{baseName}}}']){{#uniqueItems}}.toSet(){{/uniqueItems}},
{{/isEnum}}
{{^isEnum}}
{{{name}}}: json[r'{{{baseName}}}'] is Iterable
? (json[r'{{{baseName}}}'] as Iterable).cast<{{{items.datatype}}}>().{{#uniqueItems}}toSet(){{/uniqueItems}}{{^uniqueItems}}toList(growable: false){{/uniqueItems}}
: {{#defaultValue}}{{{.}}}{{/defaultValue}}{{^defaultValue}}null{{/defaultValue}},
{{/isEnum}}
{{/isArray}}
{{^isArray}}
{{#isMap}}
{{{name}}}: mapCastOfType<String, {{{items.datatype}}}>(json, r'{{{baseName}}}'){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}},
{{/isMap}}
{{^isMap}}
{{#isNumber}}
{{{name}}}: {{#isNullable}}json[r'{{{baseName}}}'] == null
? {{#defaultValue}}{{{.}}}{{/defaultValue}}{{^defaultValue}}null{{/defaultValue}}
: {{/isNullable}}{{{datatypeWithEnum}}}.parse('${json[r'{{{baseName}}}']}'),
{{/isNumber}}
{{^isNumber}}
{{^isEnum}}
{{{name}}}: mapValueOfType<{{{datatypeWithEnum}}}>(json, r'{{{baseName}}}'){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}},
{{/isEnum}}
{{#isEnum}}
{{{name}}}: {{{enumName}}}.fromJson(json[r'{{{baseName}}}']){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}},
{{/isEnum}}
{{/isNumber}}
{{/isMap}}
{{/isArray}}
{{/complexType}}
{{/isDate}}
{{/isDateTime}}
{{/vars}}
);
}
return null;
}
static List<{{{classname}}}> listFromJson(dynamic json, {bool growable = false,}) {
final result = <{{{classname}}}>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = {{{classname}}}.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, {{{classname}}}> mapFromJson(dynamic json) {
final map = <String, {{{classname}}}>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = {{{classname}}}.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of {{{classname}}}-objects as value to a dart map
static Map<String, List<{{{classname}}}>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<{{{classname}}}>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = {{{classname}}}.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
{{#vars}}
{{#required}}
'{{{baseName}}}',
{{/required}}
{{/vars}}
};
}
{{#vars}}
{{^isModel}}
{{#isEnum}}
{{^isContainer}}
{{>serialization/native/native_enum_inline}}
{{/isContainer}}
{{#isContainer}}
{{#mostInnerItems}}
{{>serialization/native/native_enum_inline}}
{{/mostInnerItems}}
{{/isContainer}}
{{/isEnum}}
{{/isModel}}
{{/vars}}

View File

@@ -6949,6 +6949,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,
@@ -6958,14 +6976,6 @@
"type": "string" "type": "string"
} }
}, },
{
"name": "size",
"required": true,
"in": "query",
"schema": {
"$ref": "#/components/schemas/TimeBucketSize"
}
},
{ {
"name": "tagId", "name": "tagId",
"required": false, "required": false,
@@ -7014,10 +7024,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"items": { "$ref": "#/components/schemas/TimeBucketAssetResponseDto"
"$ref": "#/components/schemas/AssetResponseDto"
},
"type": "array"
} }
} }
}, },
@@ -7102,14 +7109,6 @@
"type": "string" "type": "string"
} }
}, },
{
"name": "size",
"required": true,
"in": "query",
"schema": {
"$ref": "#/components/schemas/TimeBucketSize"
}
},
{ {
"name": "tagId", "name": "tagId",
"required": false, "required": false,
@@ -7151,7 +7150,7 @@
"application/json": { "application/json": {
"schema": { "schema": {
"items": { "items": {
"$ref": "#/components/schemas/TimeBucketResponseDto" "$ref": "#/components/schemas/TimeBucketsResponseDto"
}, },
"type": "array" "type": "array"
} }
@@ -13561,7 +13560,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"
},
"isArchived": {
"items": {
"type": "number"
},
"type": "array"
},
"isFavorite": {
"items": {
"type": "number"
},
"type": "array"
},
"isImage": {
"items": {
"type": "number"
},
"type": "array"
},
"isTrashed": {
"items": {
"type": "number"
},
"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"
}
},
"required": [
"city",
"country",
"duration",
"id",
"isArchived",
"isFavorite",
"isImage",
"isTrashed",
"livePhotoVideoId",
"localDateTime",
"ownerId",
"projectionType",
"ratio",
"thumbhash"
],
"type": "object"
},
"TimeBucketsResponseDto": {
"properties": { "properties": {
"count": { "count": {
"type": "integer" "type": "integer"
@@ -13576,13 +13699,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

@@ -1384,7 +1384,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[];
isArchived: number[];
isFavorite: number[];
isImage: number[];
isTrashed: number[];
livePhotoVideoId: (string | null)[];
localDateTime: string[];
ownerId: string[];
projectionType: (string | null)[];
ratio: number[];
/** (stack ID, stack asset count) tuple */
stack?: (string[] | null)[];
thumbhash: (string | null)[];
};
export type TimeBucketsResponseDto = {
count: number; count: number;
timeBucket: string; timeBucket: string;
}; };
@@ -3242,15 +3260,16 @@ export function tagAssets({ id, bulkIdsDto }: {
body: bulkIdsDto body: bulkIdsDto
}))); })));
} }
export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, tagId, timeBucket, userId, withPartners, withStacked }: { export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, order, page, pageSize, personId, tagId, timeBucket, userId, withPartners, withStacked }: {
albumId?: string; albumId?: string;
isArchived?: boolean; isArchived?: boolean;
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;
@@ -3259,7 +3278,7 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key,
}, 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,
isArchived, isArchived,
@@ -3267,8 +3286,9 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key,
isTrashed, isTrashed,
key, key,
order, order,
page,
pageSize,
personId, personId,
size,
tagId, tagId,
timeBucket, timeBucket,
userId, userId,
@@ -3278,7 +3298,7 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key,
...opts ...opts
})); }));
} }
export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, tagId, userId, withPartners, withStacked }: { export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, tagId, userId, withPartners, withStacked }: {
albumId?: string; albumId?: string;
isArchived?: boolean; isArchived?: boolean;
isFavorite?: boolean; isFavorite?: boolean;
@@ -3286,7 +3306,6 @@ export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key
key?: string; key?: string;
order?: AssetOrder; order?: AssetOrder;
personId?: string; personId?: string;
size: TimeBucketSize;
tagId?: string; tagId?: string;
userId?: string; userId?: string;
withPartners?: boolean; withPartners?: boolean;
@@ -3294,7 +3313,7 @@ export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key
}, 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,
isArchived, isArchived,
@@ -3303,7 +3322,6 @@ export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key
key, key,
order, order,
personId, personId,
size,
tagId, tagId,
userId, userId,
withPartners, withPartners,
@@ -3782,7 +3800,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,8 @@
import { Controller, Get, Query } from '@nestjs/common'; import { Controller, Get, Query, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { Response } from 'express';
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 +14,19 @@ 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[]> { async getTimeBucket(
return this.service.getTimeBucket(auth, dto) as Promise<AssetResponseDto[]>; @Auth() auth: AuthDto,
@Query() dto: TimeBucketAssetDto,
@Res({ passthrough: true }) res: Response,
): Promise<TimeBucketAssetResponseDto> {
res.contentType('application/json');
const jsonBucket = await this.service.getTimeBucket(auth, dto);
return jsonBucket as unknown as TimeBucketAssetResponseDto;
} }
} }

View File

@@ -165,6 +165,12 @@ export type Stack = {
assetCount?: number; assetCount?: number;
}; };
export type TimelineStack = {
id: string;
primaryAssetId: string;
assetCount: number;
};
export type AuthSharedLink = { export type AuthSharedLink = {
id: string; id: string;
expiresAt: Date | null; expiresAt: Date | null;

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 } from 'src/enum'; import { AssetStatus, AssetType } 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;
@@ -191,7 +183,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,11 @@
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 } from 'src/enum'; import { AssetOrder } from 'src/enum';
import { TimeBucketSize } from 'src/repositories/asset.repository'; import { TimeBucketAssets, TimelineStack } from 'src/services/timeline.service.types';
import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; import { Optional, 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 +42,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 implements TimelineStack {
id!: string;
primaryAssetId!: string;
assetCount!: number;
}
export class TimeBucketAssetResponseDto implements TimeBucketAssets {
id!: string[];
ownerId!: string[];
ratio!: number[];
isFavorite!: number[];
isArchived!: number[];
isTrashed!: number[];
isImage!: number[];
@ApiProperty({ type: 'array', items: { type: 'string', nullable: true } })
thumbhash!: (string | null)[];
localDateTime!: string[];
@ApiProperty({ type: 'array', items: { type: 'string', nullable: true } })
duration!: (string | null)[];
// id, count
@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

@@ -236,12 +236,12 @@ 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 "assets"."isVisible" = $2 and "assets"."isVisible" = $1
) )
select select
"timeBucket", "timeBucket",
@@ -254,37 +254,95 @@ 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."isArchived"::int as "isArchived",
assets."isFavorite"::int as "isFavorite",
(assets.type = 'IMAGE')::int as "isImage",
(assets."deletedAt" is null)::int as "isTrashed",
(assets.type = 'VIDEO')::int as "isVideo",
"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"."isArchived" = $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 "assets"."isVisible" = $2
and "stacked"."isArchived" = $1 and date_trunc('MONTH', "localDateTime" at time zone 'UTC') at time zone 'UTC' = $3
group by and not exists (
"asset_stack"."id" select
) as "stacked_assets" on "asset_stack"."id" is not null from
where "asset_stack"
( where
"asset_stack"."primaryAssetId" = "assets"."id" "asset_stack"."id" = "assets"."stackId"
or "assets"."stackId" is null 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("isArchived"), '{}') as "isArchived",
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 "assets"."isVisible" = $2 to_json(agg)::text as "assets"
and date_trunc($3, "localDateTime" at time zone 'UTC') at time zone 'UTC' = $4 from
order by "agg"
"assets"."localDateTime" desc
-- AssetRepository.getDuplicates -- AssetRepository.getDuplicates
with with

View File

@@ -67,7 +67,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)
.where('assets.isVisible', '=', true) .where('assets.isVisible', '=', true)
@@ -581,53 +580,125 @@ export class AssetRepository {
); );
} }
@GenerateSql({ params: [DummyValue.TIME_BUCKET, { size: TimeBucketSize.MONTH, withStacked: true }] }) @GenerateSql({
async getTimeBucket(timeBucket: string, options: TimeBucketOptions) { params: [DummyValue.TIME_BUCKET, { size: TimeBucketSize.MONTH, withStacked: true }, { skip: 0, take: 1000 }],
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',
sql`assets."isArchived"::int`.as('isArchived'),
sql`assets."isFavorite"::int`.as('isFavorite'),
sql`(assets.type = 'IMAGE')::int`.as('isImage'),
sql`(assets."deletedAt" is null)::int`.as('isTrashed'),
sql`(assets.type = 'VIDEO')::int`.as('isVideo'),
'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)
.where('assets.isVisible', '=', true)
.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.isArchived !== undefined, (qb) => qb.where('assets.isArchived', '=', options.isArchived!))
.$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.isArchived', '=', false)
.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.isArchived !== undefined, (qb) => qb.where('assets.isArchived', '=', options.isArchived!))
.$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', ['isArchived']), sql.lit('{}')).as('isArchived'),
.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 0s or 1s 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.isArchived', '=', false) 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)
.where('assets.isVisible', '=', true)
.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 { DatabaseAction, EntityType, Permission, SyncEntityType, SyncRequestType
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';
@@ -141,7 +142,7 @@ export class SyncService extends BaseService {
updateId, updateId,
data: { data: {
...data, ...data,
checksum: hexOrBufferToBase64(checksum), checksum: hexOrBufferToBase64(checksum)!,
thumbhash: thumbhash ? hexOrBufferToBase64(thumbhash) : null, thumbhash: thumbhash ? hexOrBufferToBase64(thumbhash) : null,
}, },
}), }),
@@ -171,7 +172,7 @@ export class SyncService extends BaseService {
updateId, updateId,
data: { data: {
...data, ...data,
checksum: hexOrBufferToBase64(checksum), checksum: hexOrBufferToBase64(checksum)!,
thumbhash: thumbhash ? hexOrBufferToBase64(thumbhash) : null, thumbhash: thumbhash ? hexOrBufferToBase64(thumbhash) : null,
}, },
}), }),

View File

@@ -1,9 +1,6 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
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, () => {
@@ -18,13 +15,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],
}); });
}); });
@@ -33,35 +27,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',
isArchived: true, isArchived: true,
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',
isArchived: true, isArchived: true,
userIds: [authStub.admin.user.id], userIds: [authStub.admin.user.id],
@@ -70,20 +63,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',
isArchived: false, isArchived: false,
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',
isArchived: false, isArchived: false,
withPartners: true, withPartners: true,
@@ -92,62 +84,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',
isArchived: true,
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',
isArchived: true,
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],
}), }),
@@ -157,7 +124,6 @@ describe(TimelineService.name, () => {
it('should throw an error if withParners is true and isArchived true or undefined', async () => { it('should throw an error if withParners is true and isArchived true or undefined', async () => {
await expect( await expect(
sut.getTimeBucket(authStub.admin, { sut.getTimeBucket(authStub.admin, {
size: TimeBucketSize.DAY,
timeBucket: 'bucket', timeBucket: 'bucket',
isArchived: true, isArchived: true,
withPartners: true, withPartners: true,
@@ -167,7 +133,6 @@ describe(TimelineService.name, () => {
await expect( await expect(
sut.getTimeBucket(authStub.admin, { sut.getTimeBucket(authStub.admin, {
size: TimeBucketSize.DAY,
timeBucket: 'bucket', timeBucket: 'bucket',
isArchived: undefined, isArchived: undefined,
withPartners: true, withPartners: true,
@@ -179,7 +144,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,
@@ -189,7 +153,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,
@@ -201,7 +164,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,7 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { AssetResponseDto, SanitizedAssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { Stack } from 'src/database';
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 { Permission } from 'src/enum'; import { 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 +9,32 @@ 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;
}
mapStack(entity?: Stack | null) {
if (!entity) {
return null;
}
return {
id: entity.id!,
primaryAssetId: entity.primaryAssetId!,
assetCount: entity.assetCount as number,
};
} }
private async buildTimeBucketOptions(auth: AuthDto, dto: TimeBucketDto): Promise<TimeBucketOptions> { private async buildTimeBucketOptions(auth: AuthDto, dto: TimeBucketDto): Promise<TimeBucketOptions> {

View File

@@ -0,0 +1,28 @@
export type TimelineStack = {
id: string;
primaryAssetId: string;
assetCount: number;
};
export type AssetDescription = {
city: string | null;
country: string | null;
};
export type TimeBucketAssets = {
id: string[];
ownerId: string[];
ratio: number[];
isFavorite: number[];
isArchived: number[];
isTrashed: number[];
isImage: number[];
thumbhash: (string | null)[];
localDateTime: string[];
stack?: ([string, string] | null)[];
duration: (string | null)[];
projectionType: (string | null)[];
livePhotoVideoId: (string | null)[];
city: (string | null)[];
country: (string | null)[];
};

View File

@@ -197,3 +197,16 @@ export const asRequest = (request: AuthRequest, file: Express.Multer.File) => {
file: mapToUploadFile(file as ImmichFile), file: mapToUploadFile(file as ImmichFile),
}; };
}; };
function isRotated90CW(orientation: number) {
return orientation === 5 || orientation === 6 || orientation === 90;
}
function isRotated270CW(orientation: number) {
return orientation === 7 || orientation === 8 || orientation === -90;
}
export function isFlipped(orientation?: string | null) {
const value = Number(orientation);
return value && (isRotated270CW(value) || isRotated90CW(value));
}

View File

@@ -22,3 +22,15 @@ 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 | null) => {
if (!encoded) {
return null;
}
if (typeof encoded === 'string') {
return Buffer.from(encoded.slice(2), 'hex').toString('base64');
}
return encoded.toString('base64');
};

View File

@@ -262,7 +262,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) {
@@ -276,6 +276,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

@@ -257,6 +257,10 @@ export const assetStub = {
duplicateId: null, duplicateId: null,
isOffline: false, isOffline: false,
stack: null, stack: null,
orientation: '',
projectionType: null,
height: 3840,
width: 2160,
}), }),
trashed: Object.freeze({ trashed: Object.freeze({

View File

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

View File

@@ -1,25 +1,25 @@
<script lang="ts"> <script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte'; import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { AssetStore } from '$lib/stores/assets-store.svelte';
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store'; import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
import { handlePromiseError } from '$lib/utils';
import { cancelMultiselect, downloadAlbum } from '$lib/utils/asset-utils';
import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader'; import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
import type { AlbumResponseDto, SharedLinkResponseDto, UserResponseDto } from '@immich/sdk'; import type { AlbumResponseDto, SharedLinkResponseDto, UserResponseDto } from '@immich/sdk';
import { AssetStore } from '$lib/stores/assets-store.svelte'; import { mdiFileImagePlusOutline, mdiFolderDownloadOutline } from '@mdi/js';
import { cancelMultiselect, downloadAlbum } from '$lib/utils/asset-utils'; import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import DownloadAction from '../photos-page/actions/download-action.svelte'; import DownloadAction from '../photos-page/actions/download-action.svelte';
import AssetGrid from '../photos-page/asset-grid.svelte'; import AssetGrid from '../photos-page/asset-grid.svelte';
import AssetSelectControlBar from '../photos-page/asset-select-control-bar.svelte';
import ControlAppBar from '../shared-components/control-app-bar.svelte'; import ControlAppBar from '../shared-components/control-app-bar.svelte';
import ImmichLogoSmallLink from '../shared-components/immich-logo-small-link.svelte'; import ImmichLogoSmallLink from '../shared-components/immich-logo-small-link.svelte';
import ThemeButton from '../shared-components/theme-button.svelte'; import ThemeButton from '../shared-components/theme-button.svelte';
import { shortcut } from '$lib/actions/shortcut';
import { mdiFileImagePlusOutline, mdiFolderDownloadOutline } from '@mdi/js';
import { handlePromiseError } from '$lib/utils';
import AlbumSummary from './album-summary.svelte'; import AlbumSummary from './album-summary.svelte';
import { t } from 'svelte-i18n';
import { onDestroy } from 'svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
interface Props { interface Props {
sharedLink: SharedLinkResponseDto; sharedLink: SharedLinkResponseDto;

View File

@@ -1,18 +1,19 @@
import type { AssetAction } from '$lib/constants'; import type { AssetAction } from '$lib/constants';
import type { AlbumResponseDto, AssetResponseDto } from '@immich/sdk'; import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
import type { AlbumResponseDto } from '@immich/sdk';
type ActionMap = { type ActionMap = {
[AssetAction.ARCHIVE]: { asset: AssetResponseDto }; [AssetAction.ARCHIVE]: { asset: TimelineAsset };
[AssetAction.UNARCHIVE]: { asset: AssetResponseDto }; [AssetAction.UNARCHIVE]: { asset: TimelineAsset };
[AssetAction.FAVORITE]: { asset: AssetResponseDto }; [AssetAction.FAVORITE]: { asset: TimelineAsset };
[AssetAction.UNFAVORITE]: { asset: AssetResponseDto }; [AssetAction.UNFAVORITE]: { asset: TimelineAsset };
[AssetAction.TRASH]: { asset: AssetResponseDto }; [AssetAction.TRASH]: { asset: TimelineAsset };
[AssetAction.DELETE]: { asset: AssetResponseDto }; [AssetAction.DELETE]: { asset: TimelineAsset };
[AssetAction.RESTORE]: { asset: AssetResponseDto }; [AssetAction.RESTORE]: { asset: TimelineAsset };
[AssetAction.ADD]: { asset: AssetResponseDto }; [AssetAction.ADD]: { asset: TimelineAsset };
[AssetAction.ADD_TO_ALBUM]: { asset: AssetResponseDto; album: AlbumResponseDto }; [AssetAction.ADD_TO_ALBUM]: { asset: TimelineAsset; album: AlbumResponseDto };
[AssetAction.UNSTACK]: { assets: AssetResponseDto[] }; [AssetAction.UNSTACK]: { assets: TimelineAsset[] };
[AssetAction.KEEP_THIS_DELETE_OTHERS]: { asset: AssetResponseDto }; [AssetAction.KEEP_THIS_DELETE_OTHERS]: { asset: TimelineAsset };
}; };
export type Action = { export type Action = {

View File

@@ -6,6 +6,7 @@
import Portal from '$lib/components/shared-components/portal/portal.svelte'; import Portal from '$lib/components/shared-components/portal/portal.svelte';
import { AssetAction } from '$lib/constants'; import { AssetAction } from '$lib/constants';
import { addAssetsToAlbum, addAssetsToNewAlbum } from '$lib/utils/asset-utils'; import { addAssetsToAlbum, addAssetsToNewAlbum } from '$lib/utils/asset-utils';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import type { AlbumResponseDto, AssetResponseDto } from '@immich/sdk'; import type { AlbumResponseDto, AssetResponseDto } from '@immich/sdk';
import { mdiImageAlbum, mdiShareVariantOutline } from '@mdi/js'; import { mdiImageAlbum, mdiShareVariantOutline } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@@ -24,14 +25,14 @@
showSelectionModal = false; showSelectionModal = false;
const album = await addAssetsToNewAlbum(albumName, [asset.id]); const album = await addAssetsToNewAlbum(albumName, [asset.id]);
if (album) { if (album) {
onAction({ type: AssetAction.ADD_TO_ALBUM, asset, album }); onAction({ type: AssetAction.ADD_TO_ALBUM, asset: toTimelineAsset(asset), album });
} }
}; };
const handleAddToAlbum = async (album: AlbumResponseDto) => { const handleAddToAlbum = async (album: AlbumResponseDto) => {
showSelectionModal = false; showSelectionModal = false;
await addAssetsToAlbum(album.id, [asset.id]); await addAssetsToAlbum(album.id, [asset.id]);
onAction({ type: AssetAction.ADD_TO_ALBUM, asset, album }); onAction({ type: AssetAction.ADD_TO_ALBUM, asset: toTimelineAsset(asset), album });
}; };
</script> </script>

View File

@@ -4,6 +4,7 @@
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 { toggleArchive } from '$lib/utils/asset-utils'; import { toggleArchive } from '$lib/utils/asset-utils';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import type { AssetResponseDto } from '@immich/sdk'; import type { AssetResponseDto } from '@immich/sdk';
import { mdiArchiveArrowDownOutline, mdiArchiveArrowUpOutline } from '@mdi/js'; import { mdiArchiveArrowDownOutline, mdiArchiveArrowUpOutline } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@@ -18,11 +19,11 @@
const onArchive = async () => { const onArchive = async () => {
if (!asset.isArchived) { if (!asset.isArchived) {
preAction({ type: AssetAction.ARCHIVE, asset }); preAction({ type: AssetAction.ARCHIVE, asset: toTimelineAsset(asset) });
} }
const updatedAsset = await toggleArchive(asset); const updatedAsset = await toggleArchive(asset);
if (updatedAsset) { if (updatedAsset) {
onAction({ type: asset.isArchived ? AssetAction.ARCHIVE : AssetAction.UNARCHIVE, asset }); onAction({ type: asset.isArchived ? AssetAction.ARCHIVE : AssetAction.UNARCHIVE, asset: toTimelineAsset(asset) });
} }
}; };
</script> </script>

View File

@@ -11,6 +11,7 @@
import { showDeleteModal } from '$lib/stores/preferences.store'; import { showDeleteModal } from '$lib/stores/preferences.store';
import { featureFlags } from '$lib/stores/server-config.store'; import { featureFlags } from '$lib/stores/server-config.store';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { deleteAssets, type AssetResponseDto } from '@immich/sdk'; import { deleteAssets, type AssetResponseDto } from '@immich/sdk';
import { mdiDeleteForeverOutline, mdiDeleteOutline } from '@mdi/js'; import { mdiDeleteForeverOutline, mdiDeleteOutline } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@@ -42,9 +43,9 @@
const trashAsset = async () => { const trashAsset = async () => {
try { try {
preAction({ type: AssetAction.TRASH, asset }); preAction({ type: AssetAction.TRASH, asset: toTimelineAsset(asset) });
await deleteAssets({ assetBulkDeleteDto: { ids: [asset.id] } }); await deleteAssets({ assetBulkDeleteDto: { ids: [asset.id] } });
onAction({ type: AssetAction.TRASH, asset }); onAction({ type: AssetAction.TRASH, asset: toTimelineAsset(asset) });
notificationController.show({ notificationController.show({
message: $t('moved_to_trash'), message: $t('moved_to_trash'),
@@ -58,7 +59,7 @@
const deleteAsset = async () => { const deleteAsset = async () => {
try { try {
await deleteAssets({ assetBulkDeleteDto: { ids: [asset.id], force: true } }); await deleteAssets({ assetBulkDeleteDto: { ids: [asset.id], force: true } });
onAction({ type: AssetAction.DELETE, asset }); onAction({ type: AssetAction.DELETE, asset: toTimelineAsset(asset) });
notificationController.show({ notificationController.show({
message: $t('permanently_deleted_asset'), message: $t('permanently_deleted_asset'),

View File

@@ -7,6 +7,7 @@
} from '$lib/components/shared-components/notification/notification'; } from '$lib/components/shared-components/notification/notification';
import { AssetAction } from '$lib/constants'; import { AssetAction } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { updateAsset, type AssetResponseDto } from '@immich/sdk'; import { updateAsset, type AssetResponseDto } from '@immich/sdk';
import { mdiHeart, mdiHeartOutline } from '@mdi/js'; import { mdiHeart, mdiHeartOutline } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@@ -30,7 +31,10 @@
asset = { ...asset, isFavorite: data.isFavorite }; asset = { ...asset, isFavorite: data.isFavorite };
onAction({ type: asset.isFavorite ? AssetAction.FAVORITE : AssetAction.UNFAVORITE, asset }); onAction({
type: asset.isFavorite ? AssetAction.FAVORITE : AssetAction.UNFAVORITE,
asset: toTimelineAsset(asset),
});
notificationController.show({ notificationController.show({
type: NotificationType.Info, type: NotificationType.Info,

View File

@@ -1,12 +1,13 @@
<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 { dialogController } from '$lib/components/shared-components/dialog/dialog';
import { AssetAction } from '$lib/constants'; import { AssetAction } from '$lib/constants';
import { keepThisDeleteOthers } from '$lib/utils/asset-utils'; import { keepThisDeleteOthers } from '$lib/utils/asset-utils';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import type { AssetResponseDto, StackResponseDto } from '@immich/sdk'; import type { AssetResponseDto, StackResponseDto } from '@immich/sdk';
import { mdiPinOutline } from '@mdi/js'; import { mdiPinOutline } from '@mdi/js';
import type { OnAction } from './action';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { dialogController } from '$lib/components/shared-components/dialog/dialog'; import type { OnAction } from './action';
interface Props { interface Props {
stack: StackResponseDto; stack: StackResponseDto;
@@ -29,7 +30,7 @@
const keptAsset = await keepThisDeleteOthers(asset, stack); const keptAsset = await keepThisDeleteOthers(asset, stack);
if (keptAsset) { if (keptAsset) {
onAction({ type: AssetAction.UNSTACK, assets: [keptAsset] }); onAction({ type: AssetAction.UNSTACK, assets: [toTimelineAsset(keptAsset)] });
} }
}; };
</script> </script>

View File

@@ -6,6 +6,7 @@
} from '$lib/components/shared-components/notification/notification'; } from '$lib/components/shared-components/notification/notification';
import { AssetAction } from '$lib/constants'; import { AssetAction } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { restoreAssets, type AssetResponseDto } from '@immich/sdk'; import { restoreAssets, type AssetResponseDto } from '@immich/sdk';
import { mdiHistory } from '@mdi/js'; import { mdiHistory } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@@ -23,7 +24,7 @@
await restoreAssets({ bulkIdsDto: { ids: [asset.id] } }); await restoreAssets({ bulkIdsDto: { ids: [asset.id] } });
asset.isTrashed = false; asset.isTrashed = false;
onAction({ type: AssetAction.RESTORE, asset }); onAction({ type: AssetAction.RESTORE, asset: toTimelineAsset(asset) });
notificationController.show({ notificationController.show({
type: NotificationType.Info, type: NotificationType.Info,

View File

@@ -2,6 +2,7 @@
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 { deleteStack } from '$lib/utils/asset-utils'; import { deleteStack } from '$lib/utils/asset-utils';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import type { StackResponseDto } from '@immich/sdk'; import type { StackResponseDto } from '@immich/sdk';
import { mdiImageMinusOutline } from '@mdi/js'; import { mdiImageMinusOutline } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@@ -17,7 +18,7 @@
const handleUnstack = async () => { const handleUnstack = async () => {
const unstackedAssets = await deleteStack([stack.id]); const unstackedAssets = await deleteStack([stack.id]);
if (unstackedAssets) { if (unstackedAssets) {
onAction({ type: AssetAction.UNSTACK, assets: unstackedAssets }); onAction({ type: AssetAction.UNSTACK, assets: unstackedAssets.map((a) => toTimelineAsset(a)) });
} }
}; };
</script> </script>

View File

@@ -13,6 +13,7 @@ describe('AssetViewerNavBar component', () => {
showDownloadButton: false, showDownloadButton: false,
showMotionPlayButton: false, showMotionPlayButton: false,
showShareButton: false, showShareButton: false,
preAction: () => {},
onZoomImage: () => {}, onZoomImage: () => {},
onCopyImage: () => {}, onCopyImage: () => {},
onAction: () => {}, onAction: () => {},

View File

@@ -9,6 +9,7 @@
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import { closeEditorCofirm } from '$lib/stores/asset-editor.store'; import { closeEditorCofirm } from '$lib/stores/asset-editor.store';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
import { isShowDetail } from '$lib/stores/preferences.store'; import { isShowDetail } from '$lib/stores/preferences.store';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { user } from '$lib/stores/user.store'; import { user } from '$lib/stores/user.store';
@@ -16,6 +17,7 @@
import { getAssetJobMessage, getSharedLink, handlePromiseError } from '$lib/utils'; import { getAssetJobMessage, getSharedLink, handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { SlideshowHistory } from '$lib/utils/slideshow-history'; import { SlideshowHistory } from '$lib/utils/slideshow-history';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { import {
AssetJobName, AssetJobName,
AssetTypeEnum, AssetTypeEnum,
@@ -47,7 +49,7 @@
interface Props { interface Props {
asset: AssetResponseDto; asset: AssetResponseDto;
preloadAssets?: AssetResponseDto[]; preloadAssets?: TimelineAsset[];
showNavigation?: boolean; showNavigation?: boolean;
withStacked?: boolean; withStacked?: boolean;
isShared?: boolean; isShared?: boolean;
@@ -56,10 +58,10 @@
preAction?: PreAction | undefined; preAction?: PreAction | undefined;
onAction?: OnAction | undefined; onAction?: OnAction | undefined;
showCloseButton?: boolean; showCloseButton?: boolean;
onClose: (dto: { asset: AssetResponseDto }) => void; onClose: (asset: AssetResponseDto) => void;
onNext: () => Promise<HasAsset>; onNext: () => Promise<HasAsset>;
onPrevious: () => Promise<HasAsset>; onPrevious: () => Promise<HasAsset>;
onRandom: () => Promise<AssetResponseDto | undefined>; onRandom: () => Promise<{ id: string } | undefined>;
copyImage?: () => Promise<void>; copyImage?: () => Promise<void>;
} }
@@ -81,7 +83,7 @@
copyImage = $bindable(), copyImage = $bindable(),
}: Props = $props(); }: Props = $props();
const { setAsset } = assetViewingStore; const { setAssetId } = assetViewingStore;
const { const {
restartProgress: restartSlideshowProgress, restartProgress: restartSlideshowProgress,
stopProgress: stopSlideshowProgress, stopProgress: stopSlideshowProgress,
@@ -121,7 +123,7 @@
untrack(() => { untrack(() => {
if (stack && stack?.assets.length > 1) { if (stack && stack?.assets.length > 1) {
preloadAssets.push(stack.assets[1]); preloadAssets.push(toTimelineAsset(stack.assets[1]));
} }
}); });
}; };
@@ -161,7 +163,7 @@
slideshowStateUnsubscribe = slideshowState.subscribe((value) => { slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
if (value === SlideshowState.PlaySlideshow) { if (value === SlideshowState.PlaySlideshow) {
slideshowHistory.reset(); slideshowHistory.reset();
slideshowHistory.queue(asset); slideshowHistory.queue(toTimelineAsset(asset));
handlePromiseError(handlePlaySlideshow()); handlePromiseError(handlePlaySlideshow());
} else if (value === SlideshowState.StopSlideshow) { } else if (value === SlideshowState.StopSlideshow) {
handlePromiseError(handleStopSlideshow()); handlePromiseError(handleStopSlideshow());
@@ -171,7 +173,7 @@
shuffleSlideshowUnsubscribe = slideshowNavigation.subscribe((value) => { shuffleSlideshowUnsubscribe = slideshowNavigation.subscribe((value) => {
if (value === SlideshowNavigation.Shuffle) { if (value === SlideshowNavigation.Shuffle) {
slideshowHistory.reset(); slideshowHistory.reset();
slideshowHistory.queue(asset); slideshowHistory.queue(toTimelineAsset(asset));
} }
}); });
@@ -225,7 +227,7 @@
}; };
const closeViewer = () => { const closeViewer = () => {
onClose({ asset }); onClose(asset);
}; };
const closeEditor = () => { const closeEditor = () => {
@@ -292,8 +294,7 @@
let assetViewerHtmlElement = $state<HTMLElement>(); let assetViewerHtmlElement = $state<HTMLElement>();
const slideshowHistory = new SlideshowHistory((asset) => { const slideshowHistory = new SlideshowHistory((asset) => {
setAsset(asset); handlePromiseError(setAssetId(asset.id).then(() => ($restartSlideshowProgress = true)));
$restartSlideshowProgress = true;
}); });
const handleVideoStarted = () => { const handleVideoStarted = () => {
@@ -562,8 +563,8 @@
imageClass={{ 'border-2 border-white': stackedAsset.id === asset.id }} imageClass={{ 'border-2 border-white': stackedAsset.id === asset.id }}
brokenAssetClass="text-xs" brokenAssetClass="text-xs"
dimmed={stackedAsset.id !== asset.id} dimmed={stackedAsset.id !== asset.id}
asset={stackedAsset} asset={toTimelineAsset(stackedAsset)}
onClick={(stackedAsset) => { onClick={() => {
asset = stackedAsset; asset = stackedAsset;
}} }}
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)} onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}

View File

@@ -1,15 +1,10 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy, tick } from 'svelte';
import { t } from 'svelte-i18n';
import { getAssetOriginalUrl } from '$lib/utils'; import { getAssetOriginalUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { getAltText } from '$lib/utils/thumbnail-util'; import { getAltText } from '$lib/utils/thumbnail-util';
import { onDestroy, onMount, tick } from 'svelte';
import { t } from 'svelte-i18n';
import { imgElement, cropAreaEl, resetCropStore, overlayEl, isResizingOrDragging, cropFrame } from './crop-store';
import { draw } from './drawing';
import { onImageLoad, resizeCanvas } from './image-loading';
import { handleMouseDown, handleMouseMove, handleMouseUp } from './mouse-handlers';
import { recalculateCrop, animateCropChange } from './crop-settings';
import { import {
changedOriention, changedOriention,
cropAspectRatio, cropAspectRatio,
@@ -17,7 +12,13 @@
resetGlobalCropStore, resetGlobalCropStore,
rotateDegrees, rotateDegrees,
} from '$lib/stores/asset-editor.store'; } from '$lib/stores/asset-editor.store';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import type { AssetResponseDto } from '@immich/sdk'; import type { AssetResponseDto } from '@immich/sdk';
import { animateCropChange, recalculateCrop } from './crop-settings';
import { cropAreaEl, cropFrame, imgElement, isResizingOrDragging, overlayEl, resetCropStore } from './crop-store';
import { draw } from './drawing';
import { onImageLoad, resizeCanvas } from './image-loading';
import { handleMouseDown, handleMouseMove, handleMouseUp } from './mouse-handlers';
interface Props { interface Props {
asset: AssetResponseDto; asset: AssetResponseDto;
@@ -81,7 +82,7 @@
aria-label="Crop area" aria-label="Crop area"
type="button" type="button"
> >
<img draggable="false" src={img?.src} alt={$getAltText(asset)} /> <img draggable="false" src={img?.src} alt={$getAltText(toTimelineAsset(asset))} />
<div class={`${$isResizingOrDragging ? 'resizing' : ''} crop-frame`} bind:this={$cropFrame}> <div class={`${$isResizingOrDragging ? 'resizing' : ''} crop-frame`} bind:this={$cropFrame}>
<div class="grid"></div> <div class="grid"></div>
<div class="corner top-left"></div> <div class="corner top-left"></div>

View File

@@ -1,31 +1,32 @@
<script lang="ts"> <script lang="ts">
import { shortcuts } from '$lib/actions/shortcut'; import { shortcuts } from '$lib/actions/shortcut';
import { zoomImageAction, zoomed } from '$lib/actions/zoom-image'; import { zoomImageAction, zoomed } from '$lib/actions/zoom-image';
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
import BrokenAsset from '$lib/components/assets/broken-asset.svelte'; import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import { photoViewerImgElement, type TimelineAsset } from '$lib/stores/assets-store.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { boundingBoxesArray } from '$lib/stores/people.store'; import { boundingBoxesArray } from '$lib/stores/people.store';
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store'; import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store'; import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
import { photoZoomState } from '$lib/stores/zoom-image.store'; import { photoZoomState } from '$lib/stores/zoom-image.store';
import { getAssetOriginalUrl, getAssetThumbnailUrl, handlePromiseError } from '$lib/utils'; import { getAssetOriginalUrl, getAssetThumbnailUrl, handlePromiseError } from '$lib/utils';
import { canCopyImageToClipboard, copyImageToClipboard, isWebCompatibleImage } from '$lib/utils/asset-utils'; import { canCopyImageToClipboard, copyImageToClipboard, isWebCompatibleImage } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { getBoundingBox } from '$lib/utils/people-utils'; import { getBoundingBox } from '$lib/utils/people-utils';
import { cancelImageUrl } from '$lib/utils/sw-messaging';
import { getAltText } from '$lib/utils/thumbnail-util'; import { getAltText } from '$lib/utils/thumbnail-util';
import { AssetMediaSize, AssetTypeEnum, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk'; import { toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetMediaSize, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { swipe, type SwipeCustomEvent } from 'svelte-gestures';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { type SwipeCustomEvent, swipe } from 'svelte-gestures';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import LoadingSpinner from '../shared-components/loading-spinner.svelte'; import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import { NotificationType, notificationController } from '../shared-components/notification/notification'; import { NotificationType, notificationController } from '../shared-components/notification/notification';
import { handleError } from '$lib/utils/handle-error';
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { cancelImageUrl, preloadImageUrl } from '$lib/utils/sw-messaging';
interface Props { interface Props {
asset: AssetResponseDto; asset: AssetResponseDto;
preloadAssets?: AssetResponseDto[] | undefined; preloadAssets?: TimelineAsset[] | undefined;
element?: HTMLDivElement | undefined; element?: HTMLDivElement | undefined;
haveFadeTransition?: boolean; haveFadeTransition?: boolean;
sharedLink?: SharedLinkResponseDto | undefined; sharedLink?: SharedLinkResponseDto | undefined;
@@ -69,10 +70,11 @@
$boundingBoxesArray = []; $boundingBoxesArray = [];
}); });
const preload = (targetSize: AssetMediaSize | 'original', preloadAssets?: AssetResponseDto[]) => { const preload = (targetSize: AssetMediaSize | 'original', preloadAssets?: TimelineAsset[]) => {
for (const preloadAsset of preloadAssets || []) { for (const preloadAsset of preloadAssets || []) {
if (preloadAsset.type === AssetTypeEnum.Image) { if (preloadAsset.isImage) {
preloadImageUrl(getAssetUrl(preloadAsset.id, targetSize, preloadAsset.thumbhash)); let img = new Image();
img.src = getAssetUrl(preloadAsset.id, targetSize, preloadAsset.thumbhash);
} }
} }
}; };
@@ -197,7 +199,7 @@
bind:clientWidth={containerWidth} bind:clientWidth={containerWidth}
bind:clientHeight={containerHeight} bind:clientHeight={containerHeight}
> >
<img style="display:none" src={imageLoaderUrl} alt={$getAltText(asset)} {onload} {onerror} /> <img style="display:none" src={imageLoaderUrl} alt="" {onload} {onerror} />
{#if !imageLoaded} {#if !imageLoaded}
<div id="spinner" class="flex h-full items-center justify-center"> <div id="spinner" class="flex h-full items-center justify-center">
<LoadingSpinner /> <LoadingSpinner />
@@ -213,7 +215,7 @@
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground} {#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
<img <img
src={assetFileUrl} src={assetFileUrl}
alt={$getAltText(asset)} alt=""
class="absolute top-0 start-0 -z-10 object-cover h-full w-full blur-lg" class="absolute top-0 start-0 -z-10 object-cover h-full w-full blur-lg"
draggable="false" draggable="false"
/> />
@@ -221,7 +223,7 @@
<img <img
bind:this={$photoViewerImgElement} bind:this={$photoViewerImgElement}
src={assetFileUrl} src={assetFileUrl}
alt={$getAltText(asset)} alt={$getAltText(toTimelineAsset(asset))}
class="h-full w-full {$slideshowState === SlideshowState.None class="h-full w-full {$slideshowState === SlideshowState.None
? 'object-contain' ? 'object-contain'
: slideshowLookCssMapping[$slideshowLook]}" : slideshowLookCssMapping[$slideshowLook]}"

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, AssetTypeEnum, type AssetResponseDto } from '@immich/sdk'; import { AssetMediaSize } from '@immich/sdk';
import { import {
mdiArchiveArrowDownOutline, mdiArchiveArrowDownOutline,
mdiCameraBurst, mdiCameraBurst,
@@ -18,6 +18,7 @@
import { thumbhash } from '$lib/actions/thumbhash'; import { thumbhash } from '$lib/actions/thumbhash';
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte'; import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { focusNext } from '$lib/utils/focus-util'; import { focusNext } from '$lib/utils/focus-util';
import { currentUrlReplaceAssetId } from '$lib/utils/navigation'; import { currentUrlReplaceAssetId } from '$lib/utils/navigation';
@@ -29,11 +30,11 @@
import VideoThumbnail from './video-thumbnail.svelte'; import VideoThumbnail from './video-thumbnail.svelte';
interface Props { interface Props {
asset: AssetResponseDto; asset: TimelineAsset;
groupIndex?: number; groupIndex?: number;
thumbnailSize?: number | undefined; thumbnailSize?: number;
thumbnailWidth?: number | undefined; thumbnailWidth?: number;
thumbnailHeight?: number | undefined; thumbnailHeight?: number;
selected?: boolean; selected?: boolean;
selectionCandidate?: boolean; selectionCandidate?: boolean;
disabled?: boolean; disabled?: boolean;
@@ -44,10 +45,10 @@
imageClass?: ClassValue; imageClass?: ClassValue;
brokenAssetClass?: ClassValue; brokenAssetClass?: ClassValue;
dimmed?: boolean; dimmed?: boolean;
onClick?: ((asset: AssetResponseDto) => void) | undefined; onClick?: (asset: TimelineAsset) => void;
onSelect?: ((asset: AssetResponseDto) => void) | undefined; onSelect?: (asset: TimelineAsset) => void;
onMouseEvent?: ((event: { isMouseOver: boolean; selectedGroupIndex: number }) => void) | undefined; onMouseEvent?: (event: { isMouseOver: boolean; selectedGroupIndex: number }) => void;
handleFocus?: (() => void) | undefined; handleFocus?: () => void;
} }
let { let {
@@ -319,7 +320,7 @@
</div> </div>
{/if} {/if}
{#if asset.type === AssetTypeEnum.Image && asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR} {#if asset.isImage && asset.projectionType === ProjectionType.EQUIRECTANGULAR}
<div class="absolute end-0 top-0 z-10 flex place-items-center gap-1 text-xs font-medium text-white"> <div class="absolute end-0 top-0 z-10 flex place-items-center gap-1 text-xs font-medium text-white">
<span class="pe-2 pt-2"> <span class="pe-2 pt-2">
<Icon path={mdiRotate360} size="24" /> <Icon path={mdiRotate360} size="24" />
@@ -332,7 +333,7 @@
<div <div
class={[ class={[
'absolute z-10 flex place-items-center gap-1 text-xs font-medium text-white', 'absolute z-10 flex place-items-center gap-1 text-xs font-medium text-white',
asset.type == AssetTypeEnum.Image && !asset.livePhotoVideoId ? 'top-0 end-0' : 'top-7 end-1', asset.isImage && !asset.livePhotoVideoId ? 'top-0 end-0' : 'top-7 end-1',
]} ]}
> >
<span class="pe-2 pt-2 flex place-items-center gap-1"> <span class="pe-2 pt-2 flex place-items-center gap-1">
@@ -352,17 +353,17 @@
curve={selected} curve={selected}
onComplete={(errored) => ((loaded = true), (thumbError = errored))} onComplete={(errored) => ((loaded = true), (thumbError = errored))}
/> />
{#if asset.type === AssetTypeEnum.Video} {#if asset.isVideo}
<div class="absolute top-0 h-full w-full"> <div class="absolute top-0 h-full w-full">
<VideoThumbnail <VideoThumbnail
url={getAssetPlaybackUrl({ id: asset.id, cacheKey: asset.thumbhash })} url={getAssetPlaybackUrl({ id: asset.id, cacheKey: asset.thumbhash })}
enablePlayback={mouseOver && $playVideoThumbnailOnHover} enablePlayback={mouseOver && $playVideoThumbnailOnHover}
curve={selected} curve={selected}
durationInSeconds={timeToSeconds(asset.duration)} durationInSeconds={timeToSeconds(asset.duration!)}
playbackOnIconHover={!$playVideoThumbnailOnHover} playbackOnIconHover={!$playVideoThumbnailOnHover}
/> />
</div> </div>
{:else if asset.type === AssetTypeEnum.Image && asset.livePhotoVideoId} {:else if asset.isImage && asset.livePhotoVideoId}
<div class="absolute top-0 h-full w-full"> <div class="absolute top-0 h-full w-full">
<VideoThumbnail <VideoThumbnail
url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, cacheKey: asset.thumbhash })} url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, cacheKey: asset.thumbhash })}

View File

@@ -24,16 +24,18 @@
NotificationType, NotificationType,
} from '$lib/components/shared-components/notification/notification'; } from '$lib/components/shared-components/notification/notification';
import { AppRoute, QueryParameter } from '$lib/constants'; import { AppRoute, QueryParameter } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { type Viewport } from '$lib/stores/assets-store.svelte'; import { type TimelineAsset, type Viewport } from '$lib/stores/assets-store.svelte';
import { type MemoryAsset, memoryStore } from '$lib/stores/memory.store.svelte'; import { type MemoryAsset, memoryStore } from '$lib/stores/memory.store.svelte';
import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store'; import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
import { preferences } from '$lib/stores/user.store'; import { preferences } from '$lib/stores/user.store';
import { getAssetPlaybackUrl, getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils'; import { getAssetPlaybackUrl, getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
import { cancelMultiselect } from '$lib/utils/asset-utils'; import { cancelMultiselect } from '$lib/utils/asset-utils';
import { fromLocalDateTime } from '$lib/utils/timeline-util'; import { getAltText } from '$lib/utils/thumbnail-util';
import { AssetMediaSize, type AssetResponseDto, AssetTypeEnum } from '@immich/sdk'; import { fromLocalDateTime, toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetMediaSize, getAssetInfo } from '@immich/sdk';
import { IconButton } from '@immich/ui'; import { IconButton } from '@immich/ui';
import { import {
mdiCardsOutline, mdiCardsOutline,
@@ -66,6 +68,11 @@
let playerInitialized = $state(false); let playerInitialized = $state(false);
let paused = $state(false); let paused = $state(false);
let current = $state<MemoryAsset | undefined>(undefined); let current = $state<MemoryAsset | undefined>(undefined);
let currentMemoryAssetFull = $derived.by(async () =>
current?.asset ? await getAssetInfo({ id: current?.asset.id, key: authManager.key }) : undefined,
);
let currentTimelineAssets = $derived(current?.memory.assets.map((a) => toTimelineAsset(a)) || []);
let isSaved = $derived(current?.memory.isSaved); let isSaved = $derived(current?.memory.isSaved);
let viewerHeight = $state(0); let viewerHeight = $state(0);
@@ -76,8 +83,8 @@
const assetInteraction = new AssetInteraction(); const assetInteraction = new AssetInteraction();
let progressBarController: Tween<number> | undefined = $state(undefined); let progressBarController: Tween<number> | undefined = $state(undefined);
let videoPlayer: HTMLVideoElement | undefined = $state(); let videoPlayer: HTMLVideoElement | undefined = $state();
const asHref = (asset: AssetResponseDto) => `?${QueryParameter.ID}=${asset.id}`; const asHref = (asset: { id: string }) => `?${QueryParameter.ID}=${asset.id}`;
const handleNavigate = async (asset?: AssetResponseDto) => { const handleNavigate = async (asset?: { id: string }) => {
if ($isViewing) { if ($isViewing) {
return asset; return asset;
} }
@@ -88,9 +95,9 @@
await goto(asHref(asset)); await goto(asHref(asset));
}; };
const setProgressDuration = (asset: AssetResponseDto) => { const setProgressDuration = (asset: TimelineAsset) => {
if (asset.type === AssetTypeEnum.Video) { if (asset.isVideo) {
const timeParts = asset.duration.split(':').map(Number); const timeParts = asset.duration!.split(':').map(Number);
const durationInMilliseconds = (timeParts[0] * 3600 + timeParts[1] * 60 + timeParts[2]) * 1000; const durationInMilliseconds = (timeParts[0] * 3600 + timeParts[1] * 60 + timeParts[2]) * 1000;
progressBarController = new Tween<number>(0, { progressBarController = new Tween<number>(0, {
duration: (from: number, to: number) => (to ? durationInMilliseconds * (to - from) : 0), duration: (from: number, to: number) => (to ? durationInMilliseconds * (to - from) : 0),
@@ -106,7 +113,8 @@
const handleNextMemory = () => handleNavigate(current?.nextMemory?.assets[0]); const handleNextMemory = () => handleNavigate(current?.nextMemory?.assets[0]);
const handlePreviousMemory = () => handleNavigate(current?.previousMemory?.assets[0]); const handlePreviousMemory = () => handleNavigate(current?.previousMemory?.assets[0]);
const handleEscape = async () => goto(AppRoute.PHOTOS); const handleEscape = async () => goto(AppRoute.PHOTOS);
const handleSelectAll = () => assetInteraction.selectAssets(current?.memory.assets || []); const handleSelectAll = () =>
assetInteraction.selectAssets(current?.memory.assets.map((a) => toTimelineAsset(a)) || []);
const handleAction = async (callingContext: string, action: 'reset' | 'pause' | 'play') => { const handleAction = async (callingContext: string, action: 'reset' | 'pause' | 'play') => {
// leaving these log statements here as comments. Very useful to figure out what's going on during dev! // leaving these log statements here as comments. Very useful to figure out what's going on during dev!
// console.log(`handleAction[${callingContext}] called with: ${action}`); // console.log(`handleAction[${callingContext}] called with: ${action}`);
@@ -239,7 +247,7 @@
}; };
const initPlayer = () => { const initPlayer = () => {
const isVideoAssetButPlayerHasNotLoadedYet = current && current.asset.type === AssetTypeEnum.Video && !videoPlayer; const isVideoAssetButPlayerHasNotLoadedYet = current && current.asset.isVideo && !videoPlayer;
if (playerInitialized || isVideoAssetButPlayerHasNotLoadedYet) { if (playerInitialized || isVideoAssetButPlayerHasNotLoadedYet) {
return; return;
} }
@@ -439,7 +447,7 @@
<div class="relative h-full w-full rounded-2xl bg-black"> <div class="relative h-full w-full rounded-2xl bg-black">
{#key current.asset.id} {#key current.asset.id}
<div transition:fade class="h-full w-full"> <div transition:fade class="h-full w-full">
{#if current.asset.type === AssetTypeEnum.Video} {#if current.asset.isVideo}
<video <video
bind:this={videoPlayer} bind:this={videoPlayer}
autoplay autoplay
@@ -456,7 +464,7 @@
<img <img
class="h-full w-full rounded-2xl object-contain transition-all" class="h-full w-full rounded-2xl object-contain transition-all"
src={getAssetThumbnailUrl({ id: current.asset.id, size: AssetMediaSize.Preview })} src={getAssetThumbnailUrl({ id: current.asset.id, size: AssetMediaSize.Preview })}
alt={current.asset.exifInfo?.description} alt={$getAltText(current.asset)}
draggable="false" draggable="false"
transition:fade transition:fade
/> />
@@ -549,8 +557,10 @@
})} })}
</p> </p>
<p> <p>
{current.asset.exifInfo?.city || ''} {#await currentMemoryAssetFull then asset}
{current.asset.exifInfo?.country || ''} {asset?.exifInfo?.city || ''}
{asset?.exifInfo?.country || ''}
{/await}
</p> </p>
</div> </div>
</div> </div>
@@ -621,7 +631,7 @@
<GalleryViewer <GalleryViewer
onNext={handleNextAsset} onNext={handleNextAsset}
onPrevious={handlePreviousAsset} onPrevious={handlePreviousAsset}
assets={current.memory.assets} assets={currentTimelineAssets}
viewport={galleryViewport} viewport={galleryViewport}
{assetInteraction} {assetInteraction}
slidingWindowOffset={viewerHeight} slidingWindowOffset={viewerHeight}

View File

@@ -1,11 +1,11 @@
<script lang="ts"> <script lang="ts">
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 { mdiArchiveArrowDownOutline, mdiArchiveArrowUpOutline, mdiTimerSand } from '@mdi/js'; import { mdiArchiveArrowDownOutline, mdiArchiveArrowUpOutline, mdiTimerSand } from '@mdi/js';
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';
import { getAssetControlContext } from '../asset-select-control-bar.svelte'; import { getAssetControlContext } from '../asset-select-control-bar.svelte';
import { archiveAssets } from '$lib/utils/asset-utils';
import { t } from 'svelte-i18n';
interface Props { interface Props {
onArchive?: OnArchive; onArchive?: OnArchive;

View File

@@ -6,9 +6,10 @@
} from '$lib/components/shared-components/notification/notification'; } from '$lib/components/shared-components/notification/notification';
import { getAssetJobIcon, getAssetJobMessage, getAssetJobName } from '$lib/utils'; import { getAssetJobIcon, getAssetJobMessage, getAssetJobName } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { AssetJobName, AssetTypeEnum, runAssetJobs } from '@immich/sdk'; import { isTimelineAsset } from '$lib/utils/timeline-util';
import { getAssetControlContext } from '../asset-select-control-bar.svelte'; import { AssetJobName, AssetTypeEnum, runAssetJobs, type AssetResponseDto } from '@immich/sdk';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
interface Props { interface Props {
jobs?: AssetJobName[]; jobs?: AssetJobName[];
@@ -19,7 +20,11 @@
const { clearSelect, getOwnedAssets } = getAssetControlContext(); const { clearSelect, getOwnedAssets } = getAssetControlContext();
let isAllVideos = $derived([...getOwnedAssets()].every((asset) => asset.type === AssetTypeEnum.Video)); let isAllVideos = $derived(
[...getOwnedAssets()].every((asset) =>
isTimelineAsset(asset) ? asset.isVideo : (asset as AssetResponseDto).type === AssetTypeEnum.Video,
),
);
const handleRunJob = async (name: AssetJobName) => { const handleRunJob = async (name: AssetJobName) => {
try { try {

View File

@@ -4,11 +4,11 @@
import { getSelectedAssets } from '$lib/utils/asset-utils'; import { getSelectedAssets } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { updateAssets } from '@immich/sdk'; import { updateAssets } from '@immich/sdk';
import { mdiCalendarEditOutline } from '@mdi/js';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
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';
import { getAssetControlContext } from '../asset-select-control-bar.svelte'; import { getAssetControlContext } from '../asset-select-control-bar.svelte';
import { mdiCalendarEditOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props { interface Props {
menuItem?: boolean; menuItem?: boolean;
} }

View File

@@ -1,11 +1,14 @@
<script lang="ts"> <script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { downloadArchive, downloadFile } from '$lib/utils/asset-utils';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
import { shortcut } from '$lib/actions/shortcut'; import { shortcut } from '$lib/actions/shortcut';
import { getAssetControlContext } from '../asset-select-control-bar.svelte'; import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { downloadArchive, downloadFile } from '$lib/utils/asset-utils';
import { getAssetInfo } from '@immich/sdk';
import { mdiCloudDownloadOutline, mdiFileDownloadOutline, mdiFolderDownloadOutline } from '@mdi/js'; import { mdiCloudDownloadOutline, mdiFileDownloadOutline, mdiFolderDownloadOutline } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
interface Props { interface Props {
filename?: string; filename?: string;
@@ -20,7 +23,8 @@
const assets = [...getAssets()]; const assets = [...getAssets()];
if (assets.length === 1) { if (assets.length === 1) {
clearSelect(); clearSelect();
await downloadFile(assets[0]); let asset = await getAssetInfo({ id: assets[0].id, key: authManager.key });
await downloadFile(asset);
return; return;
} }

View File

@@ -1,12 +1,16 @@
<script lang="ts"> <script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { getAssetControlContext } from '$lib/components/photos-page/asset-select-control-bar.svelte';
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { OnLink, OnUnlink } from '$lib/utils/actions'; import type { OnLink, OnUnlink } from '$lib/utils/actions';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { AssetTypeEnum, getAssetInfo, updateAsset } from '@immich/sdk'; import { toTimelineAsset } from '$lib/utils/timeline-util';
import { getAssetInfo, updateAsset } from '@immich/sdk';
import { mdiLinkOff, mdiMotionPlayOutline, mdiTimerSand } from '@mdi/js'; import { mdiLinkOff, mdiMotionPlayOutline, 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';
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
interface Props { interface Props {
onLink: OnLink; onLink: OnLink;
@@ -28,14 +32,14 @@
const handleLink = async () => { const handleLink = async () => {
let [still, motion] = [...getOwnedAssets()]; let [still, motion] = [...getOwnedAssets()];
if (still.type === AssetTypeEnum.Video) { if ((still as TimelineAsset).isVideo) {
[still, motion] = [motion, still]; [still, motion] = [motion, still];
} }
try { try {
loading = true; loading = true;
const stillResponse = await updateAsset({ id: still.id, updateAssetDto: { livePhotoVideoId: motion.id } }); const stillResponse = await updateAsset({ id: still.id, updateAssetDto: { livePhotoVideoId: motion.id } });
onLink({ still: stillResponse, motion }); onLink({ still: toTimelineAsset(stillResponse), motion: motion as TimelineAsset });
clearSelect(); clearSelect();
} catch (error) { } catch (error) {
handleError(error, $t('errors.unable_to_link_motion_video')); handleError(error, $t('errors.unable_to_link_motion_video'));
@@ -46,22 +50,22 @@
const handleUnlink = async () => { const handleUnlink = async () => {
const [still] = [...getOwnedAssets()]; const [still] = [...getOwnedAssets()];
if (still) {
const motionId = still?.livePhotoVideoId; const motionId = still.livePhotoVideoId;
if (!motionId) { if (!motionId) {
return; return;
} }
try {
try { loading = true;
loading = true; const stillResponse = await updateAsset({ id: still.id, updateAssetDto: { livePhotoVideoId: null } });
const stillResponse = await updateAsset({ id: still.id, updateAssetDto: { livePhotoVideoId: null } }); const motionResponse = await getAssetInfo({ id: motionId, key: authManager.key });
const motionResponse = await getAssetInfo({ id: motionId }); onUnlink({ still: toTimelineAsset(stillResponse), motion: toTimelineAsset(motionResponse) });
onUnlink({ still: stillResponse, motion: motionResponse }); clearSelect();
clearSelect(); } catch (error) {
} catch (error) { handleError(error, $t('errors.unable_to_unlink_motion_video'));
handleError(error, $t('errors.unable_to_unlink_motion_video')); } finally {
} finally { loading = false;
loading = false; }
} }
}; };
</script> </script>

View File

@@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { type AssetStore, isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { mdiSelectAll, mdiSelectRemove } from '@mdi/js';
import { selectAllAssets, cancelMultiselect } from '$lib/utils/asset-utils';
import { t } from 'svelte-i18n';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { type AssetStore, isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { cancelMultiselect, selectAllAssets } from '$lib/utils/asset-utils';
import { mdiSelectAll, mdiSelectRemove } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props { interface Props {
assetStore: AssetStore; assetStore: AssetStore;

View File

@@ -1,9 +1,10 @@
<script lang="ts"> <script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { getAssetControlContext } from '$lib/components/photos-page/asset-select-control-bar.svelte'; import { getAssetControlContext } from '$lib/components/photos-page/asset-select-control-bar.svelte';
import { mdiImageMinusOutline, mdiImageMultipleOutline } from '@mdi/js'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { stackAssets, deleteStack } from '$lib/utils/asset-utils';
import type { OnStack, OnUnstack } from '$lib/utils/actions'; import type { OnStack, OnUnstack } from '$lib/utils/actions';
import { deleteStack, stackAssets } from '$lib/utils/asset-utils';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { mdiImageMinusOutline, mdiImageMultipleOutline } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
interface Props { interface Props {
@@ -34,7 +35,7 @@
} }
const unstackedAssets = await deleteStack([stack.id]); const unstackedAssets = await deleteStack([stack.id]);
if (unstackedAssets) { if (unstackedAssets) {
onUnstack?.(unstackedAssets); onUnstack?.(unstackedAssets.map((a) => toTimelineAsset(a)));
} }
clearSelect(); clearSelect();
}; };

View File

@@ -1,20 +1,20 @@
<script lang="ts"> <script lang="ts">
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import { import {
type AssetStore,
type AssetBucket, type AssetBucket,
assetSnapshot, assetSnapshot,
assetsSnapshot, assetsSnapshot,
type AssetStore,
isSelectingAllAssets, isSelectingAllAssets,
type TimelineAsset,
} from '$lib/stores/assets-store.svelte'; } from '$lib/stores/assets-store.svelte';
import { navigate } from '$lib/utils/navigation'; import { navigate } from '$lib/utils/navigation';
import { getDateLocaleString } from '$lib/utils/timeline-util'; import { getDateLocaleString } from '$lib/utils/timeline-util';
import type { AssetResponseDto } from '@immich/sdk';
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
import { fly } from 'svelte/transition';
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { scale } from 'svelte/transition'; import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
import { fly, scale } from 'svelte/transition';
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
import { flip } from 'svelte/animate'; import { flip } from 'svelte/animate';
@@ -31,9 +31,9 @@
assetStore: AssetStore; assetStore: AssetStore;
assetInteraction: AssetInteraction; assetInteraction: AssetInteraction;
onSelect: ({ title, assets }: { title: string; assets: AssetResponseDto[] }) => void; onSelect: ({ title, assets }: { title: string; assets: TimelineAsset[] }) => void;
onSelectAssets: (asset: AssetResponseDto) => void; onSelectAssets: (asset: TimelineAsset) => void;
onSelectAssetCandidates: (asset: AssetResponseDto | null) => void; onSelectAssetCandidates: (asset: TimelineAsset | null) => void;
} }
let { let {
@@ -54,7 +54,7 @@
const transitionDuration = $derived.by(() => (bucket.store.suspendTransitions && !$isUploading ? 0 : 150)); const transitionDuration = $derived.by(() => (bucket.store.suspendTransitions && !$isUploading ? 0 : 150));
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100); const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
const onClick = (assetStore: AssetStore, assets: AssetResponseDto[], groupTitle: string, asset: AssetResponseDto) => { const onClick = (assetStore: AssetStore, assets: TimelineAsset[], groupTitle: string, asset: TimelineAsset) => {
if (isSelectionMode || assetInteraction.selectionActive) { if (isSelectionMode || assetInteraction.selectionActive) {
assetSelectHandler(assetStore, asset, assets, groupTitle); assetSelectHandler(assetStore, asset, assets, groupTitle);
return; return;
@@ -62,12 +62,12 @@
void navigate({ targetRoute: 'current', assetId: asset.id }); void navigate({ targetRoute: 'current', assetId: asset.id });
}; };
const handleSelectGroup = (title: string, assets: AssetResponseDto[]) => onSelect({ title, assets }); const handleSelectGroup = (title: string, assets: TimelineAsset[]) => onSelect({ title, assets });
const assetSelectHandler = ( const assetSelectHandler = (
assetStore: AssetStore, assetStore: AssetStore,
asset: AssetResponseDto, asset: TimelineAsset,
assetsInDateGroup: AssetResponseDto[], assetsInDateGroup: TimelineAsset[],
groupTitle: string, groupTitle: string,
) => { ) => {
onSelectAssets(asset); onSelectAssets(asset);
@@ -91,7 +91,7 @@
} }
}; };
const assetMouseEventHandler = (groupTitle: string, asset: AssetResponseDto | null) => { const assetMouseEventHandler = (groupTitle: string, asset: TimelineAsset | null) => {
// Show multi select icon on hover on date group // Show multi select icon on hover on date group
hoveredDateGroup = groupTitle; hoveredDateGroup = groupTitle;

View File

@@ -1,32 +1,39 @@
<script lang="ts"> <script lang="ts">
import { afterNavigate, beforeNavigate, goto } from '$app/navigation'; import { afterNavigate, beforeNavigate, goto } from '$app/navigation';
import { page } from '$app/stores';
import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut'; import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
import type { Action } from '$lib/components/asset-viewer/actions/action'; import type { Action } from '$lib/components/asset-viewer/actions/action';
import Skeleton from '$lib/components/photos-page/skeleton.svelte';
import { AppRoute, AssetAction } from '$lib/constants'; import { AppRoute, AssetAction } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { AssetBucket, assetsSnapshot, AssetStore, isSelectingAllAssets } from '$lib/stores/assets-store.svelte'; import {
AssetBucket,
assetsSnapshot,
AssetStore,
isSelectingAllAssets,
type TimelineAsset,
} from '$lib/stores/assets-store.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store'; import { showDeleteModal } from '$lib/stores/preferences.store';
import { searchStore } from '$lib/stores/search.svelte'; import { searchStore } from '$lib/stores/search.svelte';
import { featureFlags } from '$lib/stores/server-config.store'; import { featureFlags } from '$lib/stores/server-config.store';
import { handlePromiseError } from '$lib/utils'; import { handlePromiseError } from '$lib/utils';
import { deleteAssets, updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions'; import { deleteAssets, updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils'; import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
import { focusNext } from '$lib/utils/focus-util';
import { navigate } from '$lib/utils/navigation'; import { navigate } from '$lib/utils/navigation';
import { type ScrubberListener } from '$lib/utils/timeline-util'; import { type ScrubberListener } from '$lib/utils/timeline-util';
import type { AlbumResponseDto, AssetResponseDto, PersonResponseDto } from '@immich/sdk'; import { getAssetInfo, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
import { onMount, type Snippet } from 'svelte'; import { onMount, type Snippet } from 'svelte';
import type { UpdatePayload } from 'vite';
import Portal from '../shared-components/portal/portal.svelte'; import Portal from '../shared-components/portal/portal.svelte';
import Scrubber from '../shared-components/scrubber/scrubber.svelte'; import Scrubber from '../shared-components/scrubber/scrubber.svelte';
import ShowShortcuts from '../shared-components/show-shortcuts.svelte'; import ShowShortcuts from '../shared-components/show-shortcuts.svelte';
import AssetDateGroup from './asset-date-group.svelte'; import AssetDateGroup from './asset-date-group.svelte';
import DeleteAssetDialog from './delete-asset-dialog.svelte'; import DeleteAssetDialog from './delete-asset-dialog.svelte';
import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
import Skeleton from '$lib/components/photos-page/skeleton.svelte';
import { page } from '$app/stores';
import type { UpdatePayload } from 'vite';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { focusNext } from '$lib/utils/focus-util';
interface Props { interface Props {
isSelectionMode?: boolean; isSelectionMode?: boolean;
@@ -44,7 +51,7 @@
album?: AlbumResponseDto | null; album?: AlbumResponseDto | null;
person?: PersonResponseDto | null; person?: PersonResponseDto | null;
isShowDeleteConfirmation?: boolean; isShowDeleteConfirmation?: boolean;
onSelect?: (asset: AssetResponseDto) => void; onSelect?: (asset: TimelineAsset) => void;
onEscape?: () => void; onEscape?: () => void;
children?: Snippet; children?: Snippet;
empty?: Snippet; empty?: Snippet;
@@ -362,7 +369,7 @@
} }
}; };
const handleSelectAsset = (asset: AssetResponseDto) => { const handleSelectAsset = (asset: TimelineAsset) => {
if (!assetStore.albumAssets.has(asset.id)) { if (!assetStore.albumAssets.has(asset.id)) {
assetInteraction.selectAsset(asset); assetInteraction.selectAsset(asset);
} }
@@ -373,7 +380,8 @@
if (previousAsset) { if (previousAsset) {
const preloadAsset = await assetStore.getPreviousAsset(previousAsset); const preloadAsset = await assetStore.getPreviousAsset(previousAsset);
assetViewingStore.setAsset(previousAsset, preloadAsset ? [preloadAsset] : []); const asset = await getAssetInfo({ id: previousAsset.id, key: authManager.key });
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
await navigate({ targetRoute: 'current', assetId: previousAsset.id }); await navigate({ targetRoute: 'current', assetId: previousAsset.id });
} }
@@ -385,7 +393,8 @@
if (nextAsset) { if (nextAsset) {
const preloadAsset = await assetStore.getNextAsset(nextAsset); const preloadAsset = await assetStore.getNextAsset(nextAsset);
assetViewingStore.setAsset(nextAsset, preloadAsset ? [preloadAsset] : []); const asset = await getAssetInfo({ id: nextAsset.id, key: authManager.key });
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
await navigate({ targetRoute: 'current', assetId: nextAsset.id }); await navigate({ targetRoute: 'current', assetId: nextAsset.id });
} }
@@ -397,14 +406,14 @@
if (randomAsset) { if (randomAsset) {
const preloadAsset = await assetStore.getNextAsset(randomAsset); const preloadAsset = await assetStore.getNextAsset(randomAsset);
assetViewingStore.setAsset(randomAsset, preloadAsset ? [preloadAsset] : []); const asset = await getAssetInfo({ id: randomAsset.id, key: authManager.key });
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
await navigate({ targetRoute: 'current', assetId: randomAsset.id }); await navigate({ targetRoute: 'current', assetId: randomAsset.id });
return asset;
} }
return randomAsset;
}; };
const handleClose = async ({ asset }: { asset: AssetResponseDto }) => { const handleClose = async (asset: { id: string }) => {
assetViewingStore.showAssetViewer(false); assetViewingStore.showAssetViewer(false);
showSkeleton = true; showSkeleton = true;
$gridScrollTarget = { at: asset.id }; $gridScrollTarget = { at: asset.id };
@@ -420,7 +429,7 @@
case AssetAction.ARCHIVE: { case AssetAction.ARCHIVE: {
// find the next asset to show or close the viewer // find the next asset to show or close the viewer
// eslint-disable-next-line @typescript-eslint/no-unused-expressions // eslint-disable-next-line @typescript-eslint/no-unused-expressions
(await handleNext()) || (await handlePrevious()) || (await handleClose({ asset: action.asset })); (await handleNext()) || (await handlePrevious()) || (await handleClose(action.asset));
// delete after find the next one // delete after find the next one
assetStore.removeAssets([action.asset.id]); assetStore.removeAssets([action.asset.id]);
@@ -449,7 +458,7 @@
} }
}; };
let lastAssetMouseEvent: AssetResponseDto | null = $state(null); let lastAssetMouseEvent: TimelineAsset | null = $state(null);
let shiftKeyIsDown = $state(false); let shiftKeyIsDown = $state(false);
@@ -479,14 +488,14 @@
} }
}; };
const handleSelectAssetCandidates = (asset: AssetResponseDto | null) => { const handleSelectAssetCandidates = (asset: TimelineAsset | null) => {
if (asset) { if (asset) {
selectAssetCandidates(asset); selectAssetCandidates(asset);
} }
lastAssetMouseEvent = asset; lastAssetMouseEvent = asset;
}; };
const handleGroupSelect = (assetStore: AssetStore, group: string, assets: AssetResponseDto[]) => { const handleGroupSelect = (assetStore: AssetStore, group: string, assets: TimelineAsset[]) => {
if (assetInteraction.selectedGroup.has(group)) { if (assetInteraction.selectedGroup.has(group)) {
assetInteraction.removeGroupFromMultiselectGroup(group); assetInteraction.removeGroupFromMultiselectGroup(group);
for (const asset of assets) { for (const asset of assets) {
@@ -506,7 +515,7 @@
} }
}; };
const handleSelectAssets = async (asset: AssetResponseDto) => { const handleSelectAssets = async (asset: TimelineAsset) => {
if (!asset) { if (!asset) {
return; return;
} }
@@ -589,7 +598,7 @@
assetInteraction.setAssetSelectionStart(deselect ? null : asset); assetInteraction.setAssetSelectionStart(deselect ? null : asset);
}; };
const selectAssetCandidates = (endAsset: AssetResponseDto) => { const selectAssetCandidates = (endAsset: TimelineAsset) => {
if (!shiftKeyIsDown) { if (!shiftKeyIsDown) {
return; return;
} }

View File

@@ -4,8 +4,8 @@
export interface AssetControlContext { export interface AssetControlContext {
// Wrap assets in a function, because context isn't reactive. // Wrap assets in a function, because context isn't reactive.
getAssets: () => AssetResponseDto[]; // All assets includes partners' assets getAssets: () => TimelineAsset[]; // All assets includes partners' assets
getOwnedAssets: () => AssetResponseDto[]; // Only assets owned by the user getOwnedAssets: () => TimelineAsset[]; // Only assets owned by the user
clearSelect: () => void; clearSelect: () => void;
} }
@@ -14,13 +14,13 @@
</script> </script>
<script lang="ts"> <script lang="ts">
import type { AssetResponseDto } from '@immich/sdk'; import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
import { mdiClose } from '@mdi/js'; import { mdiClose } from '@mdi/js';
import ControlAppBar from '../shared-components/control-app-bar.svelte';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import ControlAppBar from '../shared-components/control-app-bar.svelte';
interface Props { interface Props {
assets: AssetResponseDto[]; assets: TimelineAsset[];
clearSelect: () => void; clearSelect: () => void;
ownerId?: string | undefined; ownerId?: string | undefined;
children?: Snippet; children?: Snippet;

View File

@@ -5,6 +5,7 @@
import { memoryStore } from '$lib/stores/memory.store.svelte'; import { memoryStore } from '$lib/stores/memory.store.svelte';
import { getAssetThumbnailUrl, memoryLaneTitle } from '$lib/utils'; import { getAssetThumbnailUrl, memoryLaneTitle } from '$lib/utils';
import { getAltText } from '$lib/utils/thumbnail-util'; import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js'; import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@@ -82,7 +83,7 @@
<img <img
class="h-full w-full rounded-xl object-cover" class="h-full w-full rounded-xl object-cover"
src={getAssetThumbnailUrl(memory.assets[0].id)} src={getAssetThumbnailUrl(memory.assets[0].id)}
alt={$t('memory_lane_title', { values: { title: $getAltText(memory.assets[0]) } })} alt={$t('memory_lane_title', { values: { title: $getAltText(toTimelineAsset(memory.assets[0])) } })}
draggable="false" draggable="false"
/> />
<p class="absolute bottom-2 start-4 z-10 text-lg text-white max-md:text-sm"> <p class="absolute bottom-2 start-4 z-10 text-lg text-white max-md:text-sm">

View File

@@ -11,7 +11,8 @@
import { cancelMultiselect, downloadArchive } from '$lib/utils/asset-utils'; import { cancelMultiselect, downloadArchive } from '$lib/utils/asset-utils';
import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader'; import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { addSharedLinkAssets, type SharedLinkResponseDto } from '@immich/sdk'; import { toTimelineAsset } from '$lib/utils/timeline-util';
import { addSharedLinkAssets, getAssetInfo, type SharedLinkResponseDto } from '@immich/sdk';
import { mdiArrowLeft, mdiFileImagePlusOutline, mdiFolderDownloadOutline, mdiSelectAll } from '@mdi/js'; import { mdiArrowLeft, mdiFileImagePlusOutline, mdiFolderDownloadOutline, mdiSelectAll } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import AssetViewer from '../asset-viewer/asset-viewer.svelte'; import AssetViewer from '../asset-viewer/asset-viewer.svelte';
@@ -33,7 +34,8 @@
const viewport: Viewport = $state({ width: 0, height: 0 }); const viewport: Viewport = $state({ width: 0, height: 0 });
const assetInteraction = new AssetInteraction(); const assetInteraction = new AssetInteraction();
let assets = $derived(sharedLink.assets); let assets = $derived(sharedLink.assets.map((a) => toTimelineAsset(a)));
let fullAsset = $derived(assets[0] ? getAssetInfo({ id: assets[0]?.id, key: authManager.key }) : null);
dragAndDropFilesStore.subscribe((value) => { dragAndDropFilesStore.subscribe((value) => {
if (value.isDragging && value.files.length > 0) { if (value.isDragging && value.files.length > 0) {
@@ -127,14 +129,16 @@
<GalleryViewer {assets} {assetInteraction} {viewport} /> <GalleryViewer {assets} {assetInteraction} {viewport} />
</section> </section>
{:else} {:else}
<AssetViewer {#await fullAsset then asset}
asset={assets[0]} <AssetViewer
showCloseButton={false} asset={asset!}
onAction={handleAction} showCloseButton={false}
onPrevious={() => Promise.resolve(false)} onAction={handleAction}
onNext={() => Promise.resolve(false)} onPrevious={() => Promise.resolve(false)}
onRandom={() => Promise.resolve(undefined)} onNext={() => Promise.resolve(false)}
onClose={() => {}} onRandom={() => Promise.resolve(undefined)}
/> onClose={() => {}}
/>
{/await}
{/if} {/if}
</section> </section>

View File

@@ -1,31 +1,32 @@
<script lang="ts"> <script lang="ts">
import { type ShortcutOptions, shortcuts } from '$lib/actions/shortcut';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
import type { Action } from '$lib/components/asset-viewer/actions/action'; import type { Action } from '$lib/components/asset-viewer/actions/action';
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte'; import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import { AppRoute, AssetAction } from '$lib/constants'; import { AppRoute, AssetAction } from '$lib/constants';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import type { Viewport } from '$lib/stores/assets-store.svelte'; import type { TimelineAsset, Viewport } from '$lib/stores/assets-store.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store'; import { showDeleteModal } from '$lib/stores/preferences.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { handlePromiseError } from '$lib/utils';
import { deleteAssets } from '$lib/utils/actions'; import { deleteAssets } from '$lib/utils/actions';
import { archiveAssets, cancelMultiselect } from '$lib/utils/asset-utils'; import { archiveAssets, cancelMultiselect } from '$lib/utils/asset-utils';
import { featureFlags } from '$lib/stores/server-config.store'; import { focusNext } from '$lib/utils/focus-util';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { getJustifiedLayoutFromAssets, type CommonJustifiedLayout } from '$lib/utils/layout-utils';
import { navigate } from '$lib/utils/navigation'; import { navigate } from '$lib/utils/navigation';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { type AssetResponseDto } from '@immich/sdk'; import { type AssetResponseDto } from '@immich/sdk';
import { debounce } from 'lodash-es';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import AssetViewer from '../../asset-viewer/asset-viewer.svelte'; import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
import ShowShortcuts from '../show-shortcuts.svelte';
import Portal from '../portal/portal.svelte';
import { handlePromiseError } from '$lib/utils';
import DeleteAssetDialog from '../../photos-page/delete-asset-dialog.svelte'; import DeleteAssetDialog from '../../photos-page/delete-asset-dialog.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import Portal from '../portal/portal.svelte';
import { debounce } from 'lodash-es'; import ShowShortcuts from '../show-shortcuts.svelte';
import { getJustifiedLayoutFromAssets, type CommonJustifiedLayout } from '$lib/utils/layout-utils';
import { focusNext } from '$lib/utils/focus-util';
interface Props { interface Props {
assets: AssetResponseDto[]; assets: (TimelineAsset | AssetResponseDto)[];
assetInteraction: AssetInteraction; assetInteraction: AssetInteraction;
disableAssetSelect?: boolean; disableAssetSelect?: boolean;
showArchiveIcon?: boolean; showArchiveIcon?: boolean;
@@ -33,9 +34,9 @@
onIntersected?: (() => void) | undefined; onIntersected?: (() => void) | undefined;
showAssetName?: boolean; showAssetName?: boolean;
isShowDeleteConfirmation?: boolean; isShowDeleteConfirmation?: boolean;
onPrevious?: (() => Promise<AssetResponseDto | undefined>) | undefined; onPrevious?: (() => Promise<{ id: string } | undefined>) | undefined;
onNext?: (() => Promise<AssetResponseDto | undefined>) | undefined; onNext?: (() => Promise<{ id: string } | undefined>) | undefined;
onRandom?: (() => Promise<AssetResponseDto | undefined>) | undefined; onRandom?: (() => Promise<{ id: string } | undefined>) | undefined;
pageHeaderOffset?: number; pageHeaderOffset?: number;
slidingWindowOffset?: number; slidingWindowOffset?: number;
} }
@@ -56,7 +57,7 @@
pageHeaderOffset = 0, pageHeaderOffset = 0,
}: Props = $props(); }: Props = $props();
let { isViewing: isViewerOpen, asset: viewingAsset, setAsset } = assetViewingStore; let { isViewing: isViewerOpen, asset: viewingAsset, setAssetId } = assetViewingStore;
let geometry: CommonJustifiedLayout | undefined = $state(); let geometry: CommonJustifiedLayout | undefined = $state();
@@ -83,19 +84,26 @@
containerHeight = geometry.containerHeight; containerHeight = geometry.containerHeight;
containerWidth = geometry.containerWidth; containerWidth = geometry.containerWidth;
for (const [i, asset] of assets.entries()) { for (const [i, asset] of assets.entries()) {
const layout = { const top = geometry.getTop(i);
asset, const left = geometry.getLeft(i);
top: geometry.getTop(i), const width = geometry.getWidth(i);
left: geometry.getLeft(i), const height = geometry.getHeight(i);
width: geometry.getWidth(i),
height: geometry.getHeight(i), const layoutTopWithOffset = top + pageHeaderOffset;
}; const layoutBottom = layoutTopWithOffset + height;
// 54 is the content height of the asset-selection-app-bar
const layoutTopWithOffset = layout.top + pageHeaderOffset;
const layoutBottom = layoutTopWithOffset + layout.height;
const display = layoutTopWithOffset < slidingWindow.bottom && layoutBottom > slidingWindow.top; const display = layoutTopWithOffset < slidingWindow.bottom && layoutBottom > slidingWindow.top;
assetLayout.push({ ...layout, display });
const layout = {
asset,
top,
left,
width,
height,
display,
};
assetLayout.push(layout);
} }
} }
@@ -109,7 +117,7 @@
let showShortcuts = $state(false); let showShortcuts = $state(false);
let currentViewAssetIndex = 0; let currentViewAssetIndex = 0;
let shiftKeyIsDown = $state(false); let shiftKeyIsDown = $state(false);
let lastAssetMouseEvent: AssetResponseDto | null = $state(null); let lastAssetMouseEvent: TimelineAsset | null = $state(null);
let slidingWindow = $state({ top: 0, bottom: 0 }); let slidingWindow = $state({ top: 0, bottom: 0 });
const updateSlidingWindow = () => { const updateSlidingWindow = () => {
@@ -139,14 +147,14 @@
} }
} }
}); });
const viewAssetHandler = async (asset: AssetResponseDto) => { const viewAssetHandler = async (asset: TimelineAsset) => {
currentViewAssetIndex = assets.findIndex((a) => a.id == asset.id); currentViewAssetIndex = assets.findIndex((a) => a.id == asset.id);
setAsset(assets[currentViewAssetIndex]); await setAssetId(assets[currentViewAssetIndex].id);
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id }); await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
}; };
const selectAllAssets = () => { const selectAllAssets = () => {
assetInteraction.selectAssets(assets); assetInteraction.selectAssets(assets.map((a) => toTimelineAsset(a)));
}; };
const deselectAllAssets = () => { const deselectAllAssets = () => {
@@ -168,7 +176,7 @@
} }
}; };
const handleSelectAssets = (asset: AssetResponseDto) => { const handleSelectAssets = (asset: TimelineAsset) => {
if (!asset) { if (!asset) {
return; return;
} }
@@ -191,14 +199,14 @@
assetInteraction.setAssetSelectionStart(deselect ? null : asset); assetInteraction.setAssetSelectionStart(deselect ? null : asset);
}; };
const handleSelectAssetCandidates = (asset: AssetResponseDto | null) => { const handleSelectAssetCandidates = (asset: TimelineAsset | null) => {
if (asset) { if (asset) {
selectAssetCandidates(asset); selectAssetCandidates(asset);
} }
lastAssetMouseEvent = asset; lastAssetMouseEvent = asset;
}; };
const selectAssetCandidates = (endAsset: AssetResponseDto) => { const selectAssetCandidates = (endAsset: TimelineAsset) => {
if (!shiftKeyIsDown) { if (!shiftKeyIsDown) {
return; return;
} }
@@ -215,7 +223,7 @@
[start, end] = [end, start]; [start, end] = [end, start];
} }
assetInteraction.setAssetSelectionCandidates(assets.slice(start, end + 1)); assetInteraction.setAssetSelectionCandidates(assets.slice(start, end + 1).map((a) => toTimelineAsset(a)));
}; };
const onSelectStart = (e: Event) => { const onSelectStart = (e: Event) => {
@@ -293,7 +301,7 @@
const handleNext = async (): Promise<boolean> => { const handleNext = async (): Promise<boolean> => {
try { try {
let asset: AssetResponseDto | undefined; let asset: { id: string } | undefined;
if (onNext) { if (onNext) {
asset = await onNext(); asset = await onNext();
} else { } else {
@@ -317,9 +325,9 @@
} }
}; };
const handleRandom = async (): Promise<AssetResponseDto | undefined> => { const handleRandom = async (): Promise<{ id: string } | undefined> => {
try { try {
let asset: AssetResponseDto | undefined; let asset: { id: string } | undefined;
if (onRandom) { if (onRandom) {
asset = await onRandom(); asset = await onRandom();
} else { } else {
@@ -343,7 +351,7 @@
const handlePrevious = async (): Promise<boolean> => { const handlePrevious = async (): Promise<boolean> => {
try { try {
let asset: AssetResponseDto | undefined; let asset: { id: string } | undefined;
if (onPrevious) { if (onPrevious) {
asset = await onPrevious(); asset = await onPrevious();
} else { } else {
@@ -367,9 +375,9 @@
} }
}; };
const navigateToAsset = async (asset?: AssetResponseDto) => { const navigateToAsset = async (asset?: { id: string }) => {
if (asset && asset.id !== $viewingAsset.id) { if (asset && asset.id !== $viewingAsset.id) {
setAsset(asset); await setAssetId(asset.id);
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id }); await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
} }
}; };
@@ -388,14 +396,14 @@
} else if (currentViewAssetIndex === assets.length) { } else if (currentViewAssetIndex === assets.length) {
await handlePrevious(); await handlePrevious();
} else { } else {
setAsset(assets[currentViewAssetIndex]); await setAssetId(assets[currentViewAssetIndex].id);
} }
break; break;
} }
} }
}; };
const assetMouseEventHandler = (asset: AssetResponseDto | null) => { const assetMouseEventHandler = (asset: TimelineAsset | null) => {
if (assetInteraction.selectionActive) { if (assetInteraction.selectionActive) {
handleSelectAssetCandidates(asset); handleSelectAssetCandidates(asset);
} }
@@ -457,31 +465,31 @@
class="absolute" class="absolute"
style:overflow="clip" style:overflow="clip"
style="width: {layout.width}px; height: {layout.height}px; top: {layout.top}px; left: {layout.left}px" style="width: {layout.width}px; height: {layout.height}px; top: {layout.top}px; left: {layout.left}px"
title={showAssetName ? asset.originalFileName : ''}
> >
<Thumbnail <Thumbnail
readonly={disableAssetSelect} readonly={disableAssetSelect}
onClick={(asset) => { onClick={() => {
if (assetInteraction.selectionActive) { if (assetInteraction.selectionActive) {
handleSelectAssets(asset); handleSelectAssets(toTimelineAsset(asset));
return; return;
} }
void viewAssetHandler(asset); void viewAssetHandler(toTimelineAsset(asset));
}} }}
onSelect={(asset) => handleSelectAssets(asset)} onSelect={() => handleSelectAssets(toTimelineAsset(asset))}
onMouseEvent={() => assetMouseEventHandler(asset)} onMouseEvent={() => assetMouseEventHandler(toTimelineAsset(asset))}
{showArchiveIcon} {showArchiveIcon}
{asset} asset={toTimelineAsset(asset)}
selected={assetInteraction.hasSelectedAsset(asset.id)} selected={assetInteraction.hasSelectedAsset(asset.id)}
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)} selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
thumbnailWidth={layout.width} thumbnailWidth={layout.width}
thumbnailHeight={layout.height} thumbnailHeight={layout.height}
/> />
<!-- note: if using showAssetName then the 'assets' prop must be AssetResponseDto (only used by folders) -->
{#if showAssetName} {#if showAssetName}
<div <div
class="absolute text-center p-1 text-xs font-mono font-semibold w-full bottom-0 bg-gradient-to-t bg-slate-50/75 overflow-clip text-ellipsis whitespace-pre-wrap" class="absolute text-center p-1 text-xs font-mono font-semibold w-full bottom-0 bg-gradient-to-t bg-slate-50/75 overflow-clip text-ellipsis whitespace-pre-wrap"
> >
{asset.originalFileName} {(asset as AssetResponseDto).originalFileName}
</div> </div>
{/if} {/if}
</div> </div>

View File

@@ -3,6 +3,7 @@
import { getAssetThumbnailUrl } from '$lib/utils'; import { getAssetThumbnailUrl } from '$lib/utils';
import { getAssetResolution, getFileSize } from '$lib/utils/asset-utils'; import { getAssetResolution, getFileSize } from '$lib/utils/asset-utils';
import { getAltText } from '$lib/utils/thumbnail-util'; import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { type AssetResponseDto, getAllAlbums } from '@immich/sdk'; import { type AssetResponseDto, getAllAlbums } from '@immich/sdk';
import { mdiHeart, mdiImageMultipleOutline, mdiMagnifyPlus } from '@mdi/js'; import { mdiHeart, mdiImageMultipleOutline, mdiMagnifyPlus } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@@ -36,7 +37,7 @@
<!-- THUMBNAIL--> <!-- THUMBNAIL-->
<img <img
src={getAssetThumbnailUrl(asset.id)} src={getAssetThumbnailUrl(asset.id)}
alt={$getAltText(asset)} alt={$getAltText(toTimelineAsset(asset))}
title={assetData} title={assetData}
class="h-60 object-cover rounded-t-xl w-full" class="h-60 object-cover rounded-t-xl w-full"
draggable="false" draggable="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 { assetFactory } 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';
describe('AssetInteraction', () => { describe('AssetInteraction', () => {
@@ -11,8 +11,8 @@ describe('AssetInteraction', () => {
}); });
it('calculates derived values from selection', () => { it('calculates derived values from selection', () => {
assetInteraction.selectAsset(assetFactory.build({ isFavorite: true, isArchived: true, isTrashed: true })); assetInteraction.selectAsset(timelineAssetFactory.build({ isFavorite: true, isArchived: true, isTrashed: true }));
assetInteraction.selectAsset(assetFactory.build({ isFavorite: true, isArchived: false, isTrashed: false })); assetInteraction.selectAsset(timelineAssetFactory.build({ isFavorite: true, isArchived: false, isTrashed: false }));
expect(assetInteraction.selectionActive).toBe(true); expect(assetInteraction.selectionActive).toBe(true);
expect(assetInteraction.isAllTrashed).toBe(false); expect(assetInteraction.isAllTrashed).toBe(false);
@@ -22,7 +22,7 @@ describe('AssetInteraction', () => {
it('updates isAllUserOwned when the active user changes', () => { it('updates isAllUserOwned when the active user changes', () => {
const [user1, user2] = userAdminFactory.buildList(2); const [user1, user2] = userAdminFactory.buildList(2);
assetInteraction.selectAsset(assetFactory.build({ ownerId: user1.id })); assetInteraction.selectAsset(timelineAssetFactory.build({ ownerId: user1.id }));
const cleanup = $effect.root(() => { const cleanup = $effect.root(() => {
expect(assetInteraction.isAllUserOwned).toBe(false); expect(assetInteraction.isAllUserOwned).toBe(false);

View File

@@ -1,19 +1,20 @@
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
import { user } from '$lib/stores/user.store'; import { user } from '$lib/stores/user.store';
import type { AssetResponseDto, UserAdminResponseDto } from '@immich/sdk'; import 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';
export class AssetInteraction { export class AssetInteraction {
selectedAssets = $state<AssetResponseDto[]>([]); selectedAssets = $state<TimelineAsset[]>([]);
hasSelectedAsset(assetId: string) { hasSelectedAsset(assetId: string) {
return this.selectedAssets.some((asset) => asset.id === assetId); return this.selectedAssets.some((asset) => asset.id === assetId);
} }
selectedGroup = new SvelteSet<string>(); selectedGroup = new SvelteSet<string>();
assetSelectionCandidates = $state<AssetResponseDto[]>([]); assetSelectionCandidates = $state<TimelineAsset[]>([]);
hasSelectionCandidate(assetId: string) { hasSelectionCandidate(assetId: string) {
return this.assetSelectionCandidates.some((asset) => asset.id === assetId); return this.assetSelectionCandidates.some((asset) => asset.id === assetId);
} }
assetSelectionStart = $state<AssetResponseDto | null>(null); assetSelectionStart = $state<TimelineAsset | null>(null);
selectionActive = $derived(this.selectedAssets.length > 0); selectionActive = $derived(this.selectedAssets.length > 0);
private user = fromStore<UserAdminResponseDto | undefined>(user); private user = fromStore<UserAdminResponseDto | undefined>(user);
@@ -24,13 +25,13 @@ export class AssetInteraction {
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));
selectAsset(asset: AssetResponseDto) { selectAsset(asset: TimelineAsset) {
if (!this.hasSelectedAsset(asset.id)) { if (!this.hasSelectedAsset(asset.id)) {
this.selectedAssets.push(asset); this.selectedAssets.push(asset);
} }
} }
selectAssets(assets: AssetResponseDto[]) { selectAssets(assets: TimelineAsset[]) {
for (const asset of assets) { for (const asset of assets) {
this.selectAsset(asset); this.selectAsset(asset);
} }
@@ -51,11 +52,11 @@ export class AssetInteraction {
this.selectedGroup.delete(group); this.selectedGroup.delete(group);
} }
setAssetSelectionStart(asset: AssetResponseDto | null) { setAssetSelectionStart(asset: TimelineAsset | null) {
this.assetSelectionStart = asset; this.assetSelectionStart = asset;
} }
setAssetSelectionCandidates(assets: AssetResponseDto[]) { setAssetSelectionCandidates(assets: TimelineAsset[]) {
this.assetSelectionCandidates = assets; this.assetSelectionCandidates = assets;
} }

View File

@@ -1,15 +1,16 @@
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
import { type AssetGridRouteSearchParams } from '$lib/utils/navigation'; import { type AssetGridRouteSearchParams } from '$lib/utils/navigation';
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk'; import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
import { readonly, writable } from 'svelte/store'; import { readonly, writable } from 'svelte/store';
function createAssetViewingStore() { function createAssetViewingStore() {
const viewingAssetStoreState = writable<AssetResponseDto>(); const viewingAssetStoreState = writable<AssetResponseDto>();
const preloadAssets = writable<AssetResponseDto[]>([]); const preloadAssets = writable<TimelineAsset[]>([]);
const viewState = writable<boolean>(false); const viewState = writable<boolean>(false);
const gridScrollTarget = writable<AssetGridRouteSearchParams | null | undefined>(); const gridScrollTarget = writable<AssetGridRouteSearchParams | null | undefined>();
const setAsset = (asset: AssetResponseDto, assetsToPreload: AssetResponseDto[] = []) => { const setAsset = (asset: AssetResponseDto, assetsToPreload: TimelineAsset[] = []) => {
preloadAssets.set(assetsToPreload); preloadAssets.set(assetsToPreload);
viewingAssetStoreState.set(asset); viewingAssetStoreState.set(asset);
viewState.set(true); viewState.set(true);

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 } 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 });
}); });
@@ -149,9 +156,8 @@ describe('AssetStore', () => {
}); });
it('adds assets to new bucket', () => { it('adds assets to new bucket', () => {
const asset = assetFactory.build({ const asset = timelineAssetFactory.build({
localDateTime: '2024-01-20T12:00:00.000Z', localDateTime: '2024-01-20T12:00:00.000Z',
fileCreatedAt: '2024-01-20T12:00:00.000Z',
}); });
assetStore.addAssets([asset]); assetStore.addAssets([asset]);
@@ -163,9 +169,8 @@ describe('AssetStore', () => {
}); });
it('adds assets to existing bucket', () => { it('adds assets to existing bucket', () => {
const [assetOne, assetTwo] = assetFactory.buildList(2, { const [assetOne, assetTwo] = timelineAssetFactory.buildList(2, {
localDateTime: '2024-01-20T12:00:00.000Z', localDateTime: '2024-01-20T12:00:00.000Z',
fileCreatedAt: '2024-01-20T12:00:00.000Z',
}); });
assetStore.addAssets([assetOne]); assetStore.addAssets([assetOne]);
assetStore.addAssets([assetTwo]); assetStore.addAssets([assetTwo]);
@@ -177,16 +182,13 @@ describe('AssetStore', () => {
}); });
it('orders assets in buckets by descending date', () => { it('orders assets in buckets by descending date', () => {
const assetOne = assetFactory.build({ const assetOne = timelineAssetFactory.build({
fileCreatedAt: '2024-01-20T12:00:00.000Z',
localDateTime: '2024-01-20T12:00:00.000Z', localDateTime: '2024-01-20T12:00:00.000Z',
}); });
const assetTwo = assetFactory.build({ const assetTwo = timelineAssetFactory.build({
fileCreatedAt: '2024-01-15T12:00:00.000Z',
localDateTime: '2024-01-15T12:00:00.000Z', localDateTime: '2024-01-15T12:00:00.000Z',
}); });
const assetThree = assetFactory.build({ const assetThree = timelineAssetFactory.build({
fileCreatedAt: '2024-01-16T12:00:00.000Z',
localDateTime: '2024-01-16T12:00:00.000Z', localDateTime: '2024-01-16T12:00:00.000Z',
}); });
assetStore.addAssets([assetOne, assetTwo, assetThree]); assetStore.addAssets([assetOne, assetTwo, assetThree]);
@@ -200,9 +202,9 @@ describe('AssetStore', () => {
}); });
it('orders buckets by descending date', () => { it('orders buckets by descending date', () => {
const assetOne = assetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' }); const assetOne = timelineAssetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' });
const assetTwo = assetFactory.build({ localDateTime: '2024-04-20T12:00:00.000Z' }); const assetTwo = timelineAssetFactory.build({ localDateTime: '2024-04-20T12:00:00.000Z' });
const assetThree = assetFactory.build({ localDateTime: '2023-01-20T12:00:00.000Z' }); const assetThree = timelineAssetFactory.build({ localDateTime: '2023-01-20T12:00:00.000Z' });
assetStore.addAssets([assetOne, assetTwo, assetThree]); assetStore.addAssets([assetOne, assetTwo, assetThree]);
expect(assetStore.buckets.length).toEqual(3); expect(assetStore.buckets.length).toEqual(3);
@@ -213,7 +215,7 @@ describe('AssetStore', () => {
it('updates existing asset', () => { it('updates existing asset', () => {
const updateAssetsSpy = vi.spyOn(assetStore, 'updateAssets'); const updateAssetsSpy = vi.spyOn(assetStore, 'updateAssets');
const asset = assetFactory.build(); const asset = timelineAssetFactory.build();
assetStore.addAssets([asset]); assetStore.addAssets([asset]);
assetStore.addAssets([asset]); assetStore.addAssets([asset]);
@@ -223,8 +225,8 @@ describe('AssetStore', () => {
// disabled due to the wasm Justified Layout import // disabled due to the wasm Justified Layout import
it('ignores trashed assets when isTrashed is true', async () => { it('ignores trashed assets when isTrashed is true', async () => {
const asset = assetFactory.build({ isTrashed: false }); const asset = timelineAssetFactory.build({ isTrashed: false });
const trashedAsset = assetFactory.build({ isTrashed: true }); const trashedAsset = timelineAssetFactory.build({ isTrashed: true });
const assetStore = new AssetStore(); const assetStore = new AssetStore();
await assetStore.updateOptions({ isTrashed: true }); await assetStore.updateOptions({ isTrashed: true });
@@ -244,14 +246,14 @@ describe('AssetStore', () => {
}); });
it('ignores non-existing assets', () => { it('ignores non-existing assets', () => {
assetStore.updateAssets([assetFactory.build()]); assetStore.updateAssets([timelineAssetFactory.build()]);
expect(assetStore.buckets.length).toEqual(0); expect(assetStore.buckets.length).toEqual(0);
expect(assetStore.getAssets().length).toEqual(0); expect(assetStore.getAssets().length).toEqual(0);
}); });
it('updates an asset', () => { it('updates an asset', () => {
const asset = assetFactory.build({ isFavorite: false }); const asset = timelineAssetFactory.build({ isFavorite: false });
const updatedAsset = { ...asset, isFavorite: true }; const updatedAsset = { ...asset, isFavorite: true };
assetStore.addAssets([asset]); assetStore.addAssets([asset]);
@@ -264,7 +266,7 @@ describe('AssetStore', () => {
}); });
it('asset moves buckets when asset date changes', () => { it('asset moves buckets when asset date changes', () => {
const asset = assetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' }); const asset = timelineAssetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' });
const updatedAsset = { ...asset, localDateTime: '2024-03-20T12:00:00.000Z' }; const updatedAsset = { ...asset, localDateTime: '2024-03-20T12:00:00.000Z' };
assetStore.addAssets([asset]); assetStore.addAssets([asset]);
@@ -292,7 +294,7 @@ describe('AssetStore', () => {
}); });
it('ignores invalid IDs', () => { it('ignores invalid IDs', () => {
assetStore.addAssets(assetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' })); assetStore.addAssets(timelineAssetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' }));
assetStore.removeAssets(['', 'invalid', '4c7d9acc']); assetStore.removeAssets(['', 'invalid', '4c7d9acc']);
expect(assetStore.getAssets().length).toEqual(2); expect(assetStore.getAssets().length).toEqual(2);
@@ -301,7 +303,9 @@ describe('AssetStore', () => {
}); });
it('removes asset from bucket', () => { it('removes asset from bucket', () => {
const [assetOne, assetTwo] = assetFactory.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]);
@@ -311,7 +315,7 @@ describe('AssetStore', () => {
}); });
it('does not remove bucket when empty', () => { it('does not remove bucket when empty', () => {
const assets = assetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' }); const assets = timelineAssetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' });
assetStore.addAssets(assets); assetStore.addAssets(assets);
assetStore.removeAssets(assets.map((asset) => asset.id)); assetStore.removeAssets(assets.map((asset) => asset.id));
@@ -334,12 +338,10 @@ describe('AssetStore', () => {
}); });
it('populated store returns first asset', () => { it('populated store returns first asset', () => {
const assetOne = assetFactory.build({ const assetOne = timelineAssetFactory.build({
fileCreatedAt: '2024-01-20T12:00:00.000Z',
localDateTime: '2024-01-20T12:00:00.000Z', localDateTime: '2024-01-20T12:00:00.000Z',
}); });
const assetTwo = assetFactory.build({ const assetTwo = timelineAssetFactory.build({
fileCreatedAt: '2024-01-15T12:00:00.000Z',
localDateTime: '2024-01-15T12:00:00.000Z', localDateTime: '2024-01-15T12:00:00.000Z',
}); });
assetStore.addAssets([assetOne, assetTwo]); assetStore.addAssets([assetOne, assetTwo]);
@@ -349,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();
@@ -368,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 });
}); });
@@ -445,8 +449,8 @@ describe('AssetStore', () => {
}); });
it('returns the bucket index', () => { it('returns the bucket index', () => {
const assetOne = assetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' }); const assetOne = timelineAssetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' });
const assetTwo = assetFactory.build({ localDateTime: '2024-02-15T12:00:00.000Z' }); const assetTwo = timelineAssetFactory.build({ localDateTime: '2024-02-15T12:00:00.000Z' });
assetStore.addAssets([assetOne, assetTwo]); assetStore.addAssets([assetOne, assetTwo]);
expect(assetStore.getBucketIndexByAssetId(assetTwo.id)?.bucketDate).toEqual('2024-02-01T00:00:00.000Z'); expect(assetStore.getBucketIndexByAssetId(assetTwo.id)?.bucketDate).toEqual('2024-02-01T00:00:00.000Z');
@@ -454,8 +458,8 @@ describe('AssetStore', () => {
}); });
it('ignores removed buckets', () => { it('ignores removed buckets', () => {
const assetOne = assetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' }); const assetOne = timelineAssetFactory.build({ localDateTime: '2024-01-20T12:00:00.000Z' });
const assetTwo = assetFactory.build({ localDateTime: '2024-02-15T12:00:00.000Z' }); const assetTwo = timelineAssetFactory.build({ localDateTime: '2024-02-15T12:00:00.000Z' });
assetStore.addAssets([assetOne, assetTwo]); assetStore.addAssets([assetOne, assetTwo]);
assetStore.removeAssets([assetTwo.id]); assetStore.removeAssets([assetTwo.id]);

View File

@@ -1,3 +1,4 @@
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 {
@@ -6,26 +7,22 @@ import {
type CommonLayoutOptions, type CommonLayoutOptions,
type CommonPosition, type CommonPosition,
} from '$lib/utils/layout-utils'; } from '$lib/utils/layout-utils';
import { formatDateGroupTitle, fromLocalDateTime } from '$lib/utils/timeline-util'; import { toTimelineAsset } from '$lib/utils/timeline-util';
import { TUNABLES } from '$lib/utils/tunables'; import { TUNABLES } from '$lib/utils/tunables';
import { import {
AssetOrder, AssetOrder,
getAssetInfo, getAssetInfo,
getTimeBucket, getTimeBucket,
getTimeBuckets, getTimeBuckets,
TimeBucketSize, type AssetStackResponseDto,
type AssetResponseDto, 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 { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { SvelteSet } from 'svelte/reactivity'; import { SvelteSet } from 'svelte/reactivity';
import { get, writable, type Unsubscriber } from 'svelte/store'; import { get, writable, type Unsubscriber } from 'svelte/store';
import { handleError } from '../utils/handle-error'; import { handleError } from '../utils/handle-error';
import { websocketEvents } from './websocket'; import { websocketEvents } from './websocket';
const { const {
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM }, TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
} = TUNABLES; } = TUNABLES;
@@ -60,13 +57,33 @@ function updateObject(target: any, source: any): boolean {
return updated; return updated;
} }
export function assetSnapshot(asset: AssetResponseDto) { export function assetSnapshot(asset: TimelineAsset): TimelineAsset {
return $state.snapshot(asset); return $state.snapshot(asset) as TimelineAsset;
} }
export function assetsSnapshot(assets: AssetResponseDto[]) { export function assetsSnapshot(assets: TimelineAsset[]): TimelineAsset[] {
return assets.map((a) => $state.snapshot(a)); return assets.map((a) => $state.snapshot(a)) as TimelineAsset[];
} }
export type TimelineAsset = {
id: string;
ownerId: string;
ratio: number;
thumbhash: string | null;
localDateTime: Date;
isArchived: boolean;
isFavorite: boolean;
isTrashed: boolean;
isVideo: boolean;
isImage: boolean;
stack: AssetStackResponseDto | null;
duration: string | null;
projectionType: string | null;
livePhotoVideoId: string | null;
city: string | null;
country: string | null;
people: string[];
};
class IntersectingAsset { class IntersectingAsset {
// --- public --- // --- public ---
readonly #group: AssetDateGroup; readonly #group: AssetDateGroup;
@@ -90,22 +107,23 @@ class IntersectingAsset {
}); });
position: CommonPosition | undefined = $state(); position: CommonPosition | undefined = $state();
asset: AssetResponseDto | undefined = $state(); asset: TimelineAsset | undefined = $state();
id: string | undefined = $derived(this.asset?.id); id: string | undefined = $derived(this.asset?.id);
constructor(group: AssetDateGroup, asset: AssetResponseDto) { constructor(group: AssetDateGroup, asset: TimelineAsset) {
this.#group = group; this.#group = group;
this.asset = asset; this.asset = asset;
} }
} }
type AssetOperation = (asset: AssetResponseDto) => { remove: boolean }; type AssetOperation = (asset: TimelineAsset) => { remove: boolean };
type MoveAsset = { asset: AssetResponseDto; 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;
readonly index: number; readonly index: number;
readonly date: DateTime; readonly date: Date;
readonly groupTitle: string;
readonly dayOfMonth: number; readonly dayOfMonth: number;
intersetingAssets: IntersectingAsset[] = $state([]); intersetingAssets: IntersectingAsset[] = $state([]);
@@ -120,24 +138,23 @@ export class AssetDateGroup {
col = $state(0); col = $state(0);
deferredLayout = false; deferredLayout = false;
constructor(bucket: AssetBucket, index: number, date: DateTime, dayOfMonth: number) { constructor(bucket: AssetBucket, index: number, date: Date, dayOfMonth: number) {
this.index = index; this.index = index;
this.bucket = bucket; this.bucket = bucket;
this.date = date; this.date = date;
this.dayOfMonth = dayOfMonth; this.dayOfMonth = dayOfMonth;
this.groupTitle = new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()).toLocaleString(
get(locale),
{ timeZone: 'UTC', weekday: 'short', year: 'numeric', month: 'long', day: 'numeric' },
);
} }
sortAssets(sortOrder: AssetOrder = AssetOrder.Desc) { sortAssets(sortOrder: AssetOrder = AssetOrder.Desc) {
this.intersetingAssets.sort((a, b) => { if (sortOrder === AssetOrder.Asc) {
const aDate = DateTime.fromISO(a.asset!.fileCreatedAt).toUTC(); this.intersetingAssets.sort((a, b) => a.asset!.localDateTime.valueOf() - b.asset!.localDateTime.valueOf());
const bDate = DateTime.fromISO(b.asset!.fileCreatedAt).toUTC(); } else {
this.intersetingAssets.sort((a, b) => b.asset!.localDateTime.valueOf() - a.asset!.localDateTime.valueOf());
if (sortOrder === AssetOrder.Asc) { }
return aDate.diff(bDate).milliseconds;
}
return bDate.diff(aDate).milliseconds;
});
} }
getFirstAsset() { getFirstAsset() {
@@ -167,27 +184,28 @@ export class AssetDateGroup {
let changedGeometry = false; let changedGeometry = false;
for (const assetId of unprocessedIds) { for (const assetId of unprocessedIds) {
const index = this.intersetingAssets.findIndex((ia) => ia.id == assetId); const index = this.intersetingAssets.findIndex((ia) => ia.id == assetId);
if (index !== -1) { if (index === -1) {
const asset = this.intersetingAssets[index].asset!; continue;
const oldTime = asset.localDateTime; }
let { remove } = operation(asset);
const newTime = asset.localDateTime; const asset = this.intersetingAssets[index].asset!;
if (oldTime !== newTime) { const oldTime = asset.localDateTime;
const utc = DateTime.fromISO(asset.localDateTime).toUTC().startOf('month'); let { remove } = operation(asset);
const year = utc.get('year'); const newTime = asset.localDateTime;
const month = utc.get('month'); if (oldTime.valueOf() !== newTime.valueOf()) {
if (this.bucket.year !== year || this.bucket.month !== month) { const year = newTime.getUTCFullYear();
remove = true; const month = newTime.getUTCMonth();
moveAssets.push({ asset, year, month }); if (this.bucket.year !== year || this.bucket.month !== month) {
} remove = true;
} moveAssets.push({ asset, year, month });
unprocessedIds.delete(assetId);
processedIds.add(assetId);
if (remove || this.bucket.store.isExcluded(asset)) {
this.intersetingAssets.splice(index, 1);
changedGeometry = true;
} }
} }
unprocessedIds.delete(assetId);
processedIds.add(assetId);
if (remove || this.bucket.store.isExcluded(asset)) {
this.intersetingAssets.splice(index, 1);
changedGeometry = true;
}
} }
return { moveAssets, processedIds, unprocessedIds, changedGeometry }; return { moveAssets, processedIds, unprocessedIds, changedGeometry };
} }
@@ -210,10 +228,6 @@ export class AssetDateGroup {
get absoluteDateGroupTop() { get absoluteDateGroupTop() {
return this.bucket.top + this.top; return this.bucket.top + this.top;
} }
get groupTitle() {
return formatDateGroupTitle(this.date);
}
} }
export interface Viewport { export interface Viewport {
@@ -225,6 +239,26 @@ export type ViewportXY = Viewport & {
y: number; y: number;
}; };
class AddContext {
lookupCache: {
[dayOfMonth: number]: AssetDateGroup;
} = {};
unprocessedAssets: TimelineAsset[] = [];
changedDateGroups = new Set<AssetDateGroup>();
newDateGroups = new Set<AssetDateGroup>();
sort(bucket: AssetBucket, sortOrder: AssetOrder = AssetOrder.Desc) {
for (const group of this.changedDateGroups) {
group.sortAssets(sortOrder);
}
for (const group of this.newDateGroups) {
group.sortAssets(sortOrder);
}
if (this.newDateGroups.size > 0) {
bucket.sortDateGroups();
}
}
}
export class AssetBucket { export class AssetBucket {
// --- public --- // --- public ---
#intersecting: boolean = $state(false); #intersecting: boolean = $state(false);
@@ -261,22 +295,20 @@ export class AssetBucket {
readonly month: number; readonly month: number;
readonly year: number; readonly year: number;
constructor(store: AssetStore, utcDate: DateTime, initialCount: number, order: AssetOrder = AssetOrder.Desc) { constructor(store: AssetStore, date: Date, initialCount: number, order: AssetOrder = AssetOrder.Desc) {
this.store = store; this.store = store;
this.#initialCount = initialCount; this.#initialCount = initialCount;
this.#sortOrder = order; this.#sortOrder = order;
const year = utcDate.get('year'); const bucketDateFormatted = date.toLocaleString(get(locale), {
const month = utcDate.get('month');
const bucketDateFormatted = utcDate.toJSDate().toLocaleString(get(locale), {
month: 'short', month: 'short',
year: 'numeric', year: 'numeric',
timeZone: 'UTC', timeZone: 'UTC',
}); });
this.bucketDate = utcDate.toISO()!.toString(); this.bucketDate = date.toISOString();
this.bucketDateFormatted = bucketDateFormatted; this.bucketDateFormatted = bucketDateFormatted;
this.month = month; this.month = date.getUTCMonth();
this.year = year; this.year = date.getUTCFullYear();
this.loader = new CancellableTask( this.loader = new CancellableTask(
() => { () => {
@@ -316,7 +348,7 @@ export class AssetBucket {
getAssets() { getAssets() {
// eslint-disable-next-line unicorn/no-array-reduce // eslint-disable-next-line unicorn/no-array-reduce
return this.dateGroups.reduce( return this.dateGroups.reduce(
(accumulator: AssetResponseDto[], g: AssetDateGroup) => accumulator.concat(g.getAssets()), (accumulator: TimelineAsset[], g: AssetDateGroup) => accumulator.concat(g.getAssets()),
[], [],
); );
} }
@@ -333,10 +365,10 @@ export class AssetBucket {
sortDateGroups() { sortDateGroups() {
if (this.#sortOrder === AssetOrder.Asc) { if (this.#sortOrder === AssetOrder.Asc) {
return this.dateGroups.sort((a, b) => a.date.diff(b.date).milliseconds); return this.dateGroups.sort((a, b) => a.date.valueOf() - b.date.valueOf());
} }
return this.dateGroups.sort((a, b) => b.date.diff(a.date).milliseconds); return this.dateGroups.sort((a, b) => b.date.valueOf() - a.date.valueOf());
} }
runAssetOperation(ids: Set<string>, operation: AssetOperation) { runAssetOperation(ids: Set<string>, operation: AssetOperation) {
@@ -381,56 +413,78 @@ export class AssetBucket {
} }
// note - if the assets are not part of this bucket, they will not be added // note - if the assets are not part of this bucket, they will not be added
addAssets(assets: AssetResponseDto[]) { addAssets(bucketAssets: TimeBucketAssetResponseDto) {
const lookupCache: { const time1 = performance.now();
[dayOfMonth: number]: AssetDateGroup; const addContext = new AddContext();
} = {}; const people: string[] = [];
const unprocessedAssets: AssetResponseDto[] = []; for (let i = 0; i < bucketAssets.id.length; i++) {
const changedDateGroups = new Set<AssetDateGroup>(); const timelineAsset: TimelineAsset = {
const newDateGroups = new Set<AssetDateGroup>(); city: bucketAssets.city[i],
for (const asset of assets) { country: bucketAssets.country[i],
const date = DateTime.fromISO(asset.localDateTime).toUTC(); duration: bucketAssets.duration[i],
const month = date.get('month'); id: bucketAssets.id[i],
const year = date.get('year'); isArchived: Boolean(bucketAssets.isArchived[i]),
if (this.month === month && this.year === year) { isFavorite: Boolean(bucketAssets.isFavorite[i]),
const day = date.get('day'); isImage: Boolean(bucketAssets.isImage[i]),
let dateGroup: AssetDateGroup | undefined = lookupCache[day]; isTrashed: Boolean(bucketAssets.isTrashed[i]),
if (!dateGroup) { isVideo: !bucketAssets.isImage[i],
dateGroup = this.findDateGroupByDay(day); livePhotoVideoId: bucketAssets.livePhotoVideoId[i],
if (dateGroup) { localDateTime: new Date(bucketAssets.localDateTime[i]),
lookupCache[day] = dateGroup; ownerId: bucketAssets.ownerId[i],
} people,
} projectionType: bucketAssets.projectionType[i],
if (dateGroup) { ratio: bucketAssets.ratio[i],
const intersectingAsset = new IntersectingAsset(dateGroup, asset); stack: bucketAssets.stack?.[i]
if (dateGroup.intersetingAssets.some((a) => a.id === asset.id)) { ? {
console.error(`Ignoring attempt to add duplicate asset ${asset.id} to ${dateGroup.groupTitle}`); id: bucketAssets.stack[i]![0],
} else { primaryAssetId: bucketAssets.id[i],
dateGroup.intersetingAssets.push(intersectingAsset); assetCount: Number.parseInt(bucketAssets.stack[i]![1]),
changedDateGroups.add(dateGroup); }
} : null,
} else { thumbhash: bucketAssets.thumbhash[i],
dateGroup = new AssetDateGroup(this, this.dateGroups.length, date, day); };
dateGroup.intersetingAssets.push(new IntersectingAsset(dateGroup, asset)); this.addTimelineAsset(timelineAsset, addContext);
this.dateGroups.push(dateGroup);
lookupCache[day] = dateGroup;
newDateGroups.add(dateGroup);
}
} else {
unprocessedAssets.push(asset);
}
} }
for (const group of changedDateGroups) {
group.sortAssets(this.#sortOrder); addContext.sort(this, this.#sortOrder);
} const time2 = performance.now();
for (const group of newDateGroups) { const time = time2 - time1;
group.sortAssets(this.#sortOrder); console.log(`AssetBucket.addAssets took ${time}ms`);
} return addContext.unprocessedAssets;
if (newDateGroups.size > 0) {
this.sortDateGroups();
}
return unprocessedAssets;
} }
addTimelineAsset(timelineAsset: TimelineAsset, addContext: AddContext) {
const month = timelineAsset.localDateTime.getUTCMonth();
const year = timelineAsset.localDateTime.getUTCFullYear();
if (this.month === month && this.year === year) {
const day = timelineAsset.localDateTime.getUTCDay();
let dateGroup: AssetDateGroup | undefined = addContext.lookupCache[day];
if (!dateGroup) {
dateGroup = this.findDateGroupByDay(day);
if (dateGroup) {
addContext.lookupCache[day] = dateGroup;
}
}
if (dateGroup) {
const intersectingAsset = new IntersectingAsset(dateGroup, timelineAsset);
dateGroup.intersetingAssets.push(intersectingAsset);
addContext.changedDateGroups.add(dateGroup);
} else {
dateGroup = new AssetDateGroup(this, this.dateGroups.length, timelineAsset.localDateTime, day);
dateGroup.intersetingAssets.push(new IntersectingAsset(dateGroup, timelineAsset));
this.dateGroups.push(dateGroup);
addContext.lookupCache[day] = dateGroup;
addContext.newDateGroups.add(dateGroup);
}
} else {
console.warn(
`Year ${year} and month ${month} do not match bucket year ${this.year} and month ${this.month} (${this.bucketDate} vs ${timelineAsset.localDateTime.toISOString()})`,
);
addContext.unprocessedAssets.push(timelineAsset);
}
}
getRandomDateGroup() { getRandomDateGroup() {
const random = Math.floor(Math.random() * this.dateGroups.length); const random = Math.floor(Math.random() * this.dateGroups.length);
return this.dateGroups[random]; return this.dateGroups[random];
@@ -479,6 +533,7 @@ export class AssetBucket {
} }
} }
} }
get bucketHeight() { get bucketHeight() {
return this.#bucketHeight; return this.#bucketHeight;
} }
@@ -516,12 +571,12 @@ const isMismatched = (option: boolean | undefined, value: boolean): boolean =>
interface AddAsset { interface AddAsset {
type: 'add'; type: 'add';
values: AssetResponseDto[]; values: TimelineAsset[];
} }
interface UpdateAsset { interface UpdateAsset {
type: 'update'; type: 'update';
values: AssetResponseDto[]; values: TimelineAsset[];
} }
interface DeleteAsset { interface DeleteAsset {
@@ -718,9 +773,13 @@ export class AssetStore {
connect() { connect() {
this.#unsubscribers.push( this.#unsubscribers.push(
websocketEvents.on('on_upload_success', (asset) => this.#addPendingChanges({ type: 'add', values: [asset] })), websocketEvents.on('on_upload_success', (asset) =>
this.#addPendingChanges({ type: 'add', values: [toTimelineAsset(asset)] }),
),
websocketEvents.on('on_asset_trash', (ids) => this.#addPendingChanges({ type: 'trash', values: ids })), websocketEvents.on('on_asset_trash', (ids) => this.#addPendingChanges({ type: 'trash', values: ids })),
websocketEvents.on('on_asset_update', (asset) => this.#addPendingChanges({ type: 'update', values: [asset] })), websocketEvents.on('on_asset_update', (asset) =>
this.#addPendingChanges({ type: 'update', values: [toTimelineAsset(asset)] }),
),
websocketEvents.on('on_asset_delete', (id: string) => this.#addPendingChanges({ type: 'delete', values: [id] })), websocketEvents.on('on_asset_delete', (id: string) => this.#addPendingChanges({ type: 'delete', values: [id] })),
); );
} }
@@ -734,8 +793,8 @@ export class AssetStore {
#getPendingChangeBatches() { #getPendingChangeBatches() {
const batch: { const batch: {
add: AssetResponseDto[]; add: TimelineAsset[];
update: AssetResponseDto[]; update: TimelineAsset[];
remove: string[]; remove: string[];
} = { } = {
add: [], add: [],
@@ -864,13 +923,11 @@ 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,
}); });
this.buckets = timebuckets.map((bucket) => { this.buckets = timebuckets.map((bucket) => {
const utcDate = DateTime.fromISO(bucket.timeBucket).toUTC(); return new AssetBucket(this, new Date(bucket.timeBucket), bucket.count, this.#options.order);
return new AssetBucket(this, utcDate, bucket.count, this.#options.order);
}); });
this.albumAssets.clear(); this.albumAssets.clear();
this.#updateViewportGeometry(false); this.#updateViewportGeometry(false);
@@ -904,6 +961,7 @@ export class AssetStore {
await this.#initialiazeTimeBuckets(); await this.#initialiazeTimeBuckets();
}, true); }, true);
} }
public destroy() { public destroy() {
this.disconnect(); this.disconnect();
this.isInitialized = false; this.isInitialized = false;
@@ -971,6 +1029,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;
@@ -1050,9 +1109,9 @@ export class AssetStore {
cancelable = options.cancelable; cancelable = options.cancelable;
} }
const date = DateTime.fromISO(bucketDate).toUTC(); const date = new Date(bucketDate);
const year = date.get('year'); const year = date.getUTCFullYear();
const month = date.get('month'); const month = date.getUTCMonth();
const bucket = this.getBucketByDate(year, month); const bucket = this.getBucketByDate(year, month);
if (!bucket) { if (!bucket) {
return; return;
@@ -1068,32 +1127,30 @@ export class AssetStore {
// so no need to load the bucket, it already has assets // so no need to load the bucket, it already has assets
return; return;
} }
const assets = await getTimeBucket( const bucketResponse = await getTimeBucket(
{ {
...this.#options, ...this.#options,
timeBucket: bucketDate, timeBucket: bucketDate,
size: TimeBucketSize.Month,
key: authManager.key, key: authManager.key,
}, },
{ signal }, { signal },
); );
if (assets) { if (bucketResponse) {
if (this.#options.timelineAlbumId) { if (this.#options.timelineAlbumId) {
const albumAssets = await getTimeBucket( const albumAssets = await getTimeBucket(
{ {
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 asset of albumAssets) { for (const id of albumAssets.id) {
this.albumAssets.add(asset.id); this.albumAssets.add(id);
} }
} }
const unprocessed = bucket.addAssets(bucketResponse);
const unprocessed = bucket.addAssets(assets);
if (unprocessed.length > 0) { if (unprocessed.length > 0) {
console.error( console.error(
`Warning: getTimeBucket API returning assets not in requested month: ${bucket.bucketDate}, ${JSON.stringify(unprocessed.map((a) => ({ id: a.id, localDateTime: a.localDateTime })))}`, `Warning: getTimeBucket API returning assets not in requested month: ${bucket.bucketDate}, ${JSON.stringify(unprocessed.map((a) => ({ id: a.id, localDateTime: a.localDateTime })))}`,
@@ -1107,8 +1164,8 @@ export class AssetStore {
} }
} }
addAssets(assets: AssetResponseDto[]) { addAssets(assets: TimelineAsset[]) {
const assetsToUpdate: AssetResponseDto[] = []; const assetsToUpdate: TimelineAsset[] = [];
for (const asset of assets) { for (const asset of assets) {
if (this.isExcluded(asset)) { if (this.isExcluded(asset)) {
@@ -1121,7 +1178,7 @@ export class AssetStore {
this.#addAssetsToBuckets([...notUpdated]); this.#addAssetsToBuckets([...notUpdated]);
} }
#addAssetsToBuckets(assets: AssetResponseDto[]) { #addAssetsToBuckets(assets: TimelineAsset[]) {
if (assets.length === 0) { if (assets.length === 0) {
return; return;
} }
@@ -1129,16 +1186,17 @@ export class AssetStore {
const updatedDateGroups = new Set<AssetDateGroup>(); const updatedDateGroups = new Set<AssetDateGroup>();
for (const asset of assets) { for (const asset of assets) {
const utc = DateTime.fromISO(asset.localDateTime).toUTC().startOf('month'); const year = asset.localDateTime.getUTCFullYear();
const year = utc.get('year'); const month = asset.localDateTime.getUTCMonth();
const month = utc.get('month');
let bucket = this.getBucketByDate(year, month); let bucket = this.getBucketByDate(year, month);
if (!bucket) { if (!bucket) {
bucket = new AssetBucket(this, utc, 1, this.#options.order); bucket = new AssetBucket(this, asset.localDateTime, 1, this.#options.order);
this.buckets.push(bucket); this.buckets.push(bucket);
} }
bucket.addAssets([asset]); const addContext = new AddContext();
bucket.addTimelineAsset(asset, addContext);
addContext.sort(bucket, this.#options.order);
updatedBuckets.add(bucket); updatedBuckets.add(bucket);
} }
@@ -1164,7 +1222,7 @@ export class AssetStore {
await this.initTask.waitUntilCompletion(); await this.initTask.waitUntilCompletion();
let bucket = this.#findBucketForAsset(id); let bucket = this.#findBucketForAsset(id);
if (!bucket) { if (!bucket) {
const asset = await getAssetInfo({ id }); const asset = toTimelineAsset(await getAssetInfo({ id, key: authManager.key }));
if (!asset || this.isExcluded(asset)) { if (!asset || this.isExcluded(asset)) {
return; return;
} }
@@ -1176,23 +1234,21 @@ export class AssetStore {
} }
} }
async #loadBucketAtTime(localDateTime: string, options?: { cancelable: boolean }) { async #loadBucketAtTime(localDateTime: Date, options?: { cancelable: boolean }) {
let date = fromLocalDateTime(localDateTime);
// Only support TimeBucketSize.Month // Only support TimeBucketSize.Month
date = date.set({ day: 1, hour: 0, minute: 0, second: 0, millisecond: 0 }); const year = localDateTime.getUTCFullYear();
const iso = date.toISO()!; const month = localDateTime.getUTCMonth();
const year = date.get('year'); localDateTime = new Date(year, month);
const month = date.get('month'); await this.loadBucket(localDateTime.toISOString(), options);
await this.loadBucket(iso, options);
return this.getBucketByDate(year, month); return this.getBucketByDate(year, month);
} }
async #getBucketInfoForAsset(asset: AssetResponseDto, options?: { cancelable: boolean }) { async #getBucketInfoForAsset(asset: { id: string; localDateTime: Date | string }, options?: { cancelable: boolean }) {
const bucketInfo = this.#findBucketForAsset(asset.id); const bucketInfo = this.#findBucketForAsset(asset.id);
if (bucketInfo) { if (bucketInfo) {
return bucketInfo; return bucketInfo;
} }
await this.#loadBucketAtTime(asset.localDateTime, options); await this.#loadBucketAtTime(new Date(asset.localDateTime), options);
return this.#findBucketForAsset(asset.id); return this.#findBucketForAsset(asset.id);
} }
@@ -1221,7 +1277,7 @@ export class AssetStore {
const changedBuckets = new Set<AssetBucket>(); const changedBuckets = new Set<AssetBucket>();
let idsToProcess = new Set(ids); let idsToProcess = new Set(ids);
const idsProcessed = new Set<string>(); const idsProcessed = new Set<string>();
const combinedMoveAssets: { asset: AssetResponseDto; year: number; month: number }[][] = []; const combinedMoveAssets: { asset: TimelineAsset; year: number; month: number }[][] = [];
for (const bucket of this.buckets) { for (const bucket of this.buckets) {
if (idsToProcess.size > 0) { if (idsToProcess.size > 0) {
const { moveAssets, processedIds, changedGeometry } = bucket.runAssetOperation(idsToProcess, operation); const { moveAssets, processedIds, changedGeometry } = bucket.runAssetOperation(idsToProcess, operation);
@@ -1264,8 +1320,8 @@ export class AssetStore {
this.#runAssetOperation(new Set(ids), operation); this.#runAssetOperation(new Set(ids), operation);
} }
updateAssets(assets: AssetResponseDto[]) { updateAssets(assets: TimelineAsset[]) {
const lookup = new Map<string, AssetResponseDto>(assets.map((asset) => [asset.id, asset])); const lookup = new Map<string, TimelineAsset>(assets.map((asset) => [asset.id, asset]));
const { unprocessedIds } = this.#runAssetOperation(new Set(lookup.keys()), (asset) => { const { unprocessedIds } = this.#runAssetOperation(new Set(lookup.keys()), (asset) => {
updateObject(asset, lookup.get(asset.id)); updateObject(asset, lookup.get(asset.id));
return { remove: false }; return { remove: false };
@@ -1287,11 +1343,11 @@ export class AssetStore {
this.updateIntersections(); this.updateIntersections();
} }
getFirstAsset(): AssetResponseDto | undefined { getFirstAsset(): TimelineAsset | undefined {
return this.buckets[0]?.getFirstAsset(); return this.buckets[0]?.getFirstAsset();
} }
async getPreviousAsset(asset: AssetResponseDto): Promise<AssetResponseDto | undefined> { async getPreviousAsset(asset: { id: string; localDateTime: Date | string }): Promise<TimelineAsset | undefined> {
let bucket = await this.#getBucketInfoForAsset(asset); let bucket = await this.#getBucketInfoForAsset(asset);
if (!bucket) { if (!bucket) {
return; return;
@@ -1334,7 +1390,7 @@ export class AssetStore {
} }
} }
async getNextAsset(asset: AssetResponseDto): Promise<AssetResponseDto | undefined> { async getNextAsset(asset: { id: string; localDateTime: Date | string }): Promise<TimelineAsset | undefined> {
let bucket = await this.#getBucketInfoForAsset(asset); let bucket = await this.#getBucketInfoForAsset(asset);
if (!bucket) { if (!bucket) {
return; return;
@@ -1373,7 +1429,7 @@ export class AssetStore {
} }
} }
isExcluded(asset: AssetResponseDto) { isExcluded(asset: TimelineAsset) {
return ( return (
isMismatched(this.#options.isArchived, asset.isArchived) || isMismatched(this.#options.isArchived, asset.isArchived) ||
isMismatched(this.#options.isFavorite, asset.isFavorite) || isMismatched(this.#options.isFavorite, asset.isFavorite) ||

View File

@@ -1,13 +1,8 @@
import { eventManager } from '$lib/managers/event-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte';
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
import { asLocalTimeISO } from '$lib/utils/date-time'; import { asLocalTimeISO } from '$lib/utils/date-time';
import { import { toTimelineAsset } from '$lib/utils/timeline-util';
type AssetResponseDto, import { deleteMemory, type MemoryResponseDto, removeMemoryAssets, searchMemories, updateMemory } from '@immich/sdk';
deleteMemory,
type MemoryResponseDto,
removeMemoryAssets,
searchMemories,
updateMemory,
} from '@immich/sdk';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
type MemoryIndex = { type MemoryIndex = {
@@ -17,7 +12,7 @@ type MemoryIndex = {
export type MemoryAsset = MemoryIndex & { export type MemoryAsset = MemoryIndex & {
memory: MemoryResponseDto; memory: MemoryResponseDto;
asset: AssetResponseDto; asset: TimelineAsset;
previousMemory?: MemoryResponseDto; previousMemory?: MemoryResponseDto;
previous?: MemoryAsset; previous?: MemoryAsset;
next?: MemoryAsset; next?: MemoryAsset;
@@ -41,7 +36,7 @@ class MemoryStoreSvelte {
memoryIndex, memoryIndex,
previousMemory: this.memories[memoryIndex - 1], previousMemory: this.memories[memoryIndex - 1],
nextMemory: this.memories[memoryIndex + 1], nextMemory: this.memories[memoryIndex + 1],
asset, asset: toTimelineAsset(asset),
assetIndex, assetIndex,
previous, previous,
}; };

View File

@@ -1,20 +1,20 @@
import { notificationController, NotificationType } from '$lib/components/shared-components/notification/notification'; import { notificationController, NotificationType } from '$lib/components/shared-components/notification/notification';
import type { AssetStore } 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, type AssetResponseDto } from '@immich/sdk'; import { 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';
export type OnDelete = (assetIds: string[]) => void; export type OnDelete = (assetIds: string[]) => void;
export type OnRestore = (ids: string[]) => void; export type OnRestore = (ids: string[]) => void;
export type OnLink = (assets: { still: AssetResponseDto; motion: AssetResponseDto }) => void; export type OnLink = (assets: { still: TimelineAsset; motion: TimelineAsset }) => void;
export type OnUnlink = (assets: { still: AssetResponseDto; motion: AssetResponseDto }) => 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[], isArchived: boolean) => void; export type OnArchive = (ids: string[], isArchived: boolean) => 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: AssetResponseDto[]) => void; export type OnUnstack = (assets: TimelineAsset[]) => void;
export const deleteAssets = async (force: boolean, onAssetDelete: OnDelete, ids: string[]) => { export const deleteAssets = async (force: boolean, onAssetDelete: OnDelete, ids: string[]) => {
const $t = get(t); const $t = get(t);
@@ -64,11 +64,11 @@ export function updateStackedAssetInTimeline(assetStore: AssetStore, { stack, to
* @param assetStore - The asset store to update. * @param assetStore - The asset store to update.
* @param assets - The array of asset response DTOs to update in the asset store. * @param assets - The array of asset response DTOs to update in the asset store.
*/ */
export function updateUnstackedAssetInTimeline(assetStore: AssetStore, assets: AssetResponseDto[]) { export function updateUnstackedAssetInTimeline(assetStore: AssetStore, assets: TimelineAsset[]) {
assetStore.updateAssetOperation( assetStore.updateAssetOperation(
assets.map((asset) => asset.id), assets.map((asset) => asset.id),
(asset) => { (asset) => {
asset.stack = undefined; asset.stack = null;
return { remove: false }; return { remove: false };
}, },
); );

View File

@@ -6,7 +6,12 @@ import { AppRoute } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import { downloadManager } from '$lib/managers/download-manager.svelte'; import { downloadManager } from '$lib/managers/download-manager.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetsSnapshot, isSelectingAllAssets, type AssetStore } from '$lib/stores/assets-store.svelte'; import {
assetsSnapshot,
isSelectingAllAssets,
type AssetStore,
type TimelineAsset,
} from '$lib/stores/assets-store.svelte';
import { preferences } from '$lib/stores/user.store'; import { preferences } from '$lib/stores/user.store';
import { downloadRequest, withError } from '$lib/utils'; import { downloadRequest, withError } from '$lib/utils';
import { createAlbum } from '$lib/utils/album-utils'; import { createAlbum } from '$lib/utils/album-utils';
@@ -365,7 +370,7 @@ export const getAssetType = (type: AssetTypeEnum) => {
} }
}; };
export const getSelectedAssets = (assets: AssetResponseDto[], user: UserResponseDto | null): string[] => { export const getSelectedAssets = (assets: TimelineAsset[], user: UserResponseDto | null): string[] => {
const ids = [...assets].filter((a) => user && a.ownerId === user.id).map((a) => a.id); const ids = [...assets].filter((a) => user && a.ownerId === user.id).map((a) => a.id);
const numberOfIssues = [...assets].filter((a) => user && a.ownerId !== user.id).length; const numberOfIssues = [...assets].filter((a) => user && a.ownerId !== user.id).length;
@@ -384,7 +389,7 @@ export type StackResponse = {
toDeleteIds: string[]; toDeleteIds: string[];
}; };
export const stackAssets = async (assets: AssetResponseDto[], showNotification = true): Promise<StackResponse> => { export const stackAssets = async (assets: { id: string }[], showNotification = true): Promise<StackResponse> => {
if (assets.length < 2) { if (assets.length < 2) {
return { stack: undefined, toDeleteIds: [] }; return { stack: undefined, toDeleteIds: [] };
} }
@@ -404,9 +409,9 @@ export const stackAssets = async (assets: AssetResponseDto[], showNotification =
}); });
} }
for (const [index, asset] of assets.entries()) { // for (const [index, asset] of assets.entries()) {
asset.stack = index === 0 ? { id: stack.id, assetCount: stack.assets.length, primaryAssetId: asset.id } : null; // asset.stack = index === 0 ? { id: stack.id, assetCount: stack.assets.length, primaryAssetId: asset.id } : null;
} // }
return { return {
stack, stack,
@@ -524,7 +529,7 @@ export const toggleArchive = async (asset: AssetResponseDto) => {
return asset; return asset;
}; };
export const archiveAssets = async (assets: AssetResponseDto[], archive: boolean) => { export const archiveAssets = async (assets: { id: string }[], archive: boolean) => {
const isArchived = archive; const isArchived = archive;
const ids = assets.map(({ id }) => id); const ids = assets.map(({ id }) => id);
const $t = get(t); const $t = get(t);
@@ -534,9 +539,9 @@ export const archiveAssets = async (assets: AssetResponseDto[], archive: boolean
await updateAssets({ assetBulkUpdateDto: { ids, isArchived } }); await updateAssets({ assetBulkUpdateDto: { ids, isArchived } });
} }
for (const asset of assets) { // for (const asset of assets) {
asset.isArchived = isArchived; // asset.isArchived = isArchived;
} // }
notificationController.show({ notificationController.show({
message: isArchived message: isArchived

View File

@@ -1,7 +1,10 @@
import { getAssetRatio } from '$lib/utils/asset-utils';
// import { TUNABLES } from '$lib/utils/tunables'; // import { TUNABLES } from '$lib/utils/tunables';
// note: it's important that this is not imported in more than one file due to https://github.com/sveltejs/kit/issues/7805 // note: it's important that this is not imported in more than one file due to https://github.com/sveltejs/kit/issues/7805
// import { JustifiedLayout, type LayoutOptions } from '@immich/justified-layout-wasm'; // import { JustifiedLayout, type LayoutOptions } from '@immich/justified-layout-wasm';
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
import { getAssetRatio } from '$lib/utils/asset-utils';
import { isTimelineAsset } from '$lib/utils/timeline-util';
import type { AssetResponseDto } from '@immich/sdk'; import type { AssetResponseDto } from '@immich/sdk';
import createJustifiedLayout from 'justified-layout'; import createJustifiedLayout from 'justified-layout';
@@ -26,7 +29,7 @@ export type CommonLayoutOptions = {
}; };
export function getJustifiedLayoutFromAssets( export function getJustifiedLayoutFromAssets(
assets: AssetResponseDto[], assets: (TimelineAsset | AssetResponseDto)[],
options: CommonLayoutOptions, options: CommonLayoutOptions,
): CommonJustifiedLayout { ): CommonJustifiedLayout {
// if (useWasm) { // if (useWasm) {
@@ -87,7 +90,7 @@ class Adapter {
} }
} }
export function justifiedLayout(assets: AssetResponseDto[], options: CommonLayoutOptions) { export function justifiedLayout(assets: (TimelineAsset | AssetResponseDto)[], options: CommonLayoutOptions) {
const adapter = { const adapter = {
targetRowHeight: options.rowHeight, targetRowHeight: options.rowHeight,
containerWidth: options.rowWidth, containerWidth: options.rowWidth,
@@ -96,7 +99,7 @@ export function justifiedLayout(assets: AssetResponseDto[], options: CommonLayou
}; };
const result = createJustifiedLayout( const result = createJustifiedLayout(
assets.map((g) => getAssetRatio(g)), assets.map((a) => (isTimelineAsset(a) ? a.ratio : getAssetRatio(a))),
adapter, adapter,
); );
return new Adapter(result); return new Adapter(result);

View File

@@ -1,17 +1,15 @@
import type { AssetResponseDto } from '@immich/sdk';
export class SlideshowHistory { export class SlideshowHistory {
private history: AssetResponseDto[] = []; private history: { id: string }[] = [];
private index = 0; private index = 0;
constructor(private onChange: (asset: AssetResponseDto) => void) {} constructor(private onChange: (asset: { id: string }) => void) {}
reset() { reset() {
this.history = []; this.history = [];
this.index = 0; this.index = 0;
} }
queue(asset: AssetResponseDto) { queue(asset: { id: string }) {
this.history.push(asset); this.history.push(asset);
// If we were at the end of the slideshow history, move the index to the new end // If we were at the end of the slideshow history, move the index to the new end

View File

@@ -1,5 +1,5 @@
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
import { getAltText } from '$lib/utils/thumbnail-util'; import { getAltText } from '$lib/utils/thumbnail-util';
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
import { init, register, waitLocale } from 'svelte-i18n'; import { init, register, waitLocale } from 'svelte-i18n';
const onePerson = [{ name: 'person' }]; const onePerson = [{ name: 'person' }];
@@ -40,25 +40,17 @@ describe('getAltText', () => {
'generates correctly formatted alt text when isVideo=$isVideo, city=$city, country=$country, people=$people.length', 'generates correctly formatted alt text when isVideo=$isVideo, city=$city, country=$country, people=$people.length',
({ isVideo, city, country, people, expected }) => { ({ isVideo, city, country, people, expected }) => {
const asset = { const asset = {
exifInfo: { city, country }, city,
country,
people: (people || [])?.map((p: { name: string }) => p.name),
localDateTime: '2024-01-01T12:00:00.000Z', localDateTime: '2024-01-01T12:00:00.000Z',
people, isVideo,
type: isVideo ? AssetTypeEnum.Video : AssetTypeEnum.Image, isImage: !isVideo,
} as AssetResponseDto; } as unknown as TimelineAsset;
getAltText.subscribe((fn) => { getAltText.subscribe((fn) => {
expect(fn(asset)).toEqual(expected); expect(fn(asset)).toEqual(expected);
}); });
}, },
); );
it('defaults to the description, if available', () => {
const asset = {
exifInfo: { description: 'description' },
} as AssetResponseDto;
getAltText.subscribe((fn) => {
expect(fn(asset)).toEqual('description');
});
});
}); });

View File

@@ -1,8 +1,7 @@
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
import { locale } from '$lib/stores/preferences.store'; import { locale } from '$lib/stores/preferences.store';
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { derived, get } from 'svelte/store'; import { derived, get } from 'svelte/store';
import { fromLocalDateTime } from './timeline-util';
/** /**
* Calculate thumbnail size based on number of assets and viewport width * Calculate thumbnail size based on number of assets and viewport width
@@ -39,24 +38,20 @@ export function getThumbnailSize(assetCount: number, viewWidth: number): number
} }
export const getAltText = derived(t, ($t) => { export const getAltText = derived(t, ($t) => {
return (asset: AssetResponseDto) => { return (asset: TimelineAsset) => {
if (asset.exifInfo?.description) { const date = asset.localDateTime.toLocaleString(get(locale), { dateStyle: 'long', timeZone: 'UTC' });
return asset.exifInfo.description; const hasPlace = asset.city && asset.country;
}
const date = fromLocalDateTime(asset.localDateTime).toLocaleString({ dateStyle: 'long' }, { locale: get(locale) }); const peopleCount = asset.people.length;
const hasPlace = !!asset.exifInfo?.city && !!asset.exifInfo?.country; const isVideo = asset.isVideo;
const names = asset.people?.filter((p) => p.name).map((p) => p.name) ?? [];
const peopleCount = names.length;
const isVideo = asset.type === AssetTypeEnum.Video;
const values = { const values = {
date, date,
city: asset.exifInfo?.city, city: asset.city,
country: asset.exifInfo?.country, country: asset.country,
person1: names[0], person1: peopleCount > 0 ? asset.people[0] : undefined,
person2: names[1], person2: peopleCount > 1 ? asset.people[1] : undefined,
person3: names[2], person3: peopleCount > 2 ? asset.people[2] : undefined,
isVideo, isVideo,
additionalCount: peopleCount > 3 ? peopleCount - 2 : 0, additionalCount: peopleCount > 3 ? peopleCount - 2 : 0,
}; };

View File

@@ -1,4 +1,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 { AssetTypeEnum, 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';
@@ -15,6 +19,8 @@ export const fromLocalDateTime = (localDateTime: string) =>
export const fromDateTimeOriginal = (dateTimeOriginal: string, timeZone: string) => export const fromDateTimeOriginal = (dateTimeOriginal: string, timeZone: string) =>
DateTime.fromISO(dateTimeOriginal, { zone: timeZone }); DateTime.fromISO(dateTimeOriginal, { zone: timeZone });
export const startOfMonth = (date: Date) => new Date(date.getFullYear(), date.getMonth());
export function formatGroupTitle(_date: DateTime): string { export function formatGroupTitle(_date: DateTime): string {
if (!_date.isValid) { if (!_date.isValid) {
return _date.toString(); return _date.toString();
@@ -56,3 +62,37 @@ export const getDateLocaleString = (date: DateTime, opts?: LocaleOptions): strin
date.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY, opts); date.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY, opts);
export const formatDateGroupTitle = memoize(formatGroupTitle); export const formatDateGroupTitle = memoize(formatGroupTitle);
export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): TimelineAsset => {
if (isTimelineAsset(unknownAsset)) {
return unknownAsset;
}
const assetResponse = unknownAsset as AssetResponseDto;
const { width, height } = getAssetRatio(assetResponse);
const ratio = width / height;
const city = assetResponse.exifInfo?.city;
const country = assetResponse.exifInfo?.country;
const people = assetResponse.people?.map((person) => person.name) || [];
return {
id: assetResponse.id,
ownerId: assetResponse.ownerId,
ratio,
thumbhash: assetResponse.thumbhash,
localDateTime: new Date(assetResponse.localDateTime),
isFavorite: assetResponse.isFavorite,
isArchived: assetResponse.isArchived,
isTrashed: assetResponse.isTrashed,
isVideo: assetResponse.type == AssetTypeEnum.Video,
isImage: assetResponse.type == AssetTypeEnum.Image,
stack: assetResponse.stack || null,
duration: assetResponse.duration || null,
projectionType: assetResponse.exifInfo?.projectionType || null,
livePhotoVideoId: assetResponse.livePhotoVideoId || null,
city: city || null,
country: country || null,
people,
};
};
export const isTimelineAsset = (arg: AssetResponseDto | TimelineAsset): arg is TimelineAsset =>
(arg as TimelineAsset).ratio !== undefined;

View File

@@ -1,3 +1,5 @@
import { browser } from '$app/environment';
function getBoolean(string: string | null, fallback: boolean) { function getBoolean(string: string | null, fallback: boolean) {
if (string === null) { if (string === null) {
return fallback; return fallback;
@@ -10,18 +12,23 @@ function getNumber(string: string | null, fallback: number) {
} }
return Number.parseInt(string); return Number.parseInt(string);
} }
const storage = browser
? localStorage
: {
getItem: () => null,
};
export const TUNABLES = { export const TUNABLES = {
LAYOUT: { LAYOUT: {
WASM: getBoolean(localStorage.getItem('LAYOUT.WASM'), false), WASM: getBoolean(storage.getItem('LAYOUT.WASM'), false),
}, },
TIMELINE: { TIMELINE: {
INTERSECTION_EXPAND_TOP: getNumber(localStorage.getItem('TIMELINE_INTERSECTION_EXPAND_TOP'), 500), INTERSECTION_EXPAND_TOP: getNumber(storage.getItem('TIMELINE_INTERSECTION_EXPAND_TOP'), 500),
INTERSECTION_EXPAND_BOTTOM: getNumber(localStorage.getItem('TIMELINE_INTERSECTION_EXPAND_BOTTOM'), 500), INTERSECTION_EXPAND_BOTTOM: getNumber(storage.getItem('TIMELINE_INTERSECTION_EXPAND_BOTTOM'), 500),
}, },
ASSET_GRID: { ASSET_GRID: {
NAVIGATE_ON_ASSET_IN_VIEW: getBoolean(localStorage.getItem('ASSET_GRID.NAVIGATE_ON_ASSET_IN_VIEW'), false), NAVIGATE_ON_ASSET_IN_VIEW: getBoolean(storage.getItem('ASSET_GRID.NAVIGATE_ON_ASSET_IN_VIEW'), false),
}, },
IMAGE_THUMBNAIL: { IMAGE_THUMBNAIL: {
THUMBHASH_FADE_DURATION: getNumber(localStorage.getItem('THUMBHASH_FADE_DURATION'), 150), THUMBHASH_FADE_DURATION: getNumber(storage.getItem('THUMBHASH_FADE_DURATION'), 150),
}, },
}; };

View File

@@ -87,7 +87,7 @@
let { data = $bindable() }: Props = $props(); let { data = $bindable() }: Props = $props();
let { isViewing: showAssetViewer, setAsset, gridScrollTarget } = assetViewingStore; let { isViewing: showAssetViewer, setAssetId, gridScrollTarget } = assetViewingStore;
let { slideshowState, slideshowNavigation } = slideshowStore; let { slideshowState, slideshowNavigation } = slideshowStore;
let oldAt: AssetGridRouteSearchParams | null | undefined = $state(); let oldAt: AssetGridRouteSearchParams | null | undefined = $state();
@@ -169,8 +169,7 @@
? await assetStore.getRandomAsset() ? await assetStore.getRandomAsset()
: assetStore.buckets[0]?.dateGroups[0]?.intersetingAssets[0]?.asset; : assetStore.buckets[0]?.dateGroups[0]?.intersetingAssets[0]?.asset;
if (asset) { if (asset) {
setAsset(asset); handlePromiseError(setAssetId(asset.id).then(() => ($slideshowState = SlideshowState.PlaySlideshow)));
$slideshowState = SlideshowState.PlaySlideshow;
} }
}; };

View File

@@ -9,16 +9,16 @@
import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte'; import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
import AssetGrid from '$lib/components/photos-page/asset-grid.svelte'; import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte'; import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import { AssetAction } from '$lib/constants'; import { AssetAction } from '$lib/constants';
import type { PageData } from './$types'; import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import { mdiPlus, mdiDotsVertical } from '@mdi/js';
import { t } from 'svelte-i18n';
import { onDestroy } from 'svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { AssetStore } from '$lib/stores/assets-store.svelte'; import { AssetStore } from '$lib/stores/assets-store.svelte';
import { mdiDotsVertical, mdiPlus } from '@mdi/js';
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
interface Props { interface Props {
data: PageData; data: PageData;

View File

@@ -9,19 +9,19 @@
import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte'; import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte'; import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte'; import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
import AssetGrid from '$lib/components/photos-page/asset-grid.svelte'; import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte'; import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import { AssetAction } from '$lib/constants'; import { AssetAction } from '$lib/constants';
import { AssetStore } from '$lib/stores/assets-store.svelte';
import type { PageData } from './$types';
import { mdiDotsVertical, mdiPlus } from '@mdi/js';
import { t } from 'svelte-i18n';
import { onDestroy } from 'svelte';
import { preferences } from '$lib/stores/user.store';
import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { AssetStore } from '$lib/stores/assets-store.svelte';
import { preferences } from '$lib/stores/user.store';
import { mdiDotsVertical, mdiPlus } from '@mdi/js';
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
interface Props { interface Props {
data: PageData; data: PageData;
@@ -30,7 +30,7 @@
let { data }: Props = $props(); let { data }: Props = $props();
const assetStore = new AssetStore(); const assetStore = new AssetStore();
void assetStore.updateOptions({ isFavorite: true }); void assetStore.updateOptions({ isFavorite: true, withStacked: true });
onDestroy(() => assetStore.destroy()); onDestroy(() => assetStore.destroy());
const assetInteraction = new AssetInteraction(); const assetInteraction = new AssetInteraction();
@@ -76,6 +76,7 @@
<UserPageLayout hideNavbar={assetInteraction.selectionActive} title={data.meta.title} scrollbar={false}> <UserPageLayout hideNavbar={assetInteraction.selectionActive} title={data.meta.title} scrollbar={false}>
<AssetGrid <AssetGrid
enableRouting={true} enableRouting={true}
withStacked={true}
{assetStore} {assetStore}
{assetInteraction} {assetInteraction}
removeAction={AssetAction.UNFAVORITE} removeAction={AssetAction.UNFAVORITE}

View File

@@ -1,37 +1,38 @@
<script lang="ts"> <script lang="ts">
import { afterNavigate, goto, invalidateAll } from '$app/navigation'; import { afterNavigate, goto, invalidateAll } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import SkipLink from '$lib/components/elements/buttons/skip-link.svelte';
import UserPageLayout, { headerId } from '$lib/components/layouts/user-page-layout.svelte'; import UserPageLayout, { headerId } from '$lib/components/layouts/user-page-layout.svelte';
import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte';
import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte';
import AssetJobActions from '$lib/components/photos-page/actions/asset-job-actions.svelte';
import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte';
import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte';
import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte'; import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
import SideBarSection from '$lib/components/shared-components/side-bar/side-bar-section.svelte'; import SideBarSection from '$lib/components/shared-components/side-bar/side-bar-section.svelte';
import Breadcrumbs from '$lib/components/shared-components/tree/breadcrumbs.svelte';
import TreeItemThumbnails from '$lib/components/shared-components/tree/tree-item-thumbnails.svelte'; import TreeItemThumbnails from '$lib/components/shared-components/tree/tree-item-thumbnails.svelte';
import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte'; import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte';
import { AppRoute, QueryParameter } from '$lib/constants'; import { AppRoute, QueryParameter } from '$lib/constants';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import type { Viewport } from '$lib/stores/assets-store.svelte'; import type { Viewport } from '$lib/stores/assets-store.svelte';
import { foldersStore } from '$lib/stores/folders.svelte'; import { foldersStore } from '$lib/stores/folders.svelte';
import { preferences } from '$lib/stores/user.store';
import { cancelMultiselect } from '$lib/utils/asset-utils';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils'; import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils';
import { mdiDotsVertical, mdiFolder, mdiFolderHome, mdiFolderOutline, mdiPlus, mdiSelectAll } from '@mdi/js'; import { mdiDotsVertical, mdiFolder, mdiFolderHome, mdiFolderOutline, mdiPlus, mdiSelectAll } from '@mdi/js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { PageData } from './$types'; import type { PageData } from './$types';
import Breadcrumbs from '$lib/components/shared-components/tree/breadcrumbs.svelte';
import SkipLink from '$lib/components/elements/buttons/skip-link.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte';
import AssetJobActions from '$lib/components/photos-page/actions/asset-job-actions.svelte';
import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import { preferences } from '$lib/stores/user.store';
import { cancelMultiselect } from '$lib/utils/asset-utils';
import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte';
import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte';
import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte';
interface Props { interface Props {
data: PageData; data: PageData;
@@ -82,7 +83,7 @@
return; return;
} }
assetInteraction.selectAssets(data.pathAssets); assetInteraction.selectAssets(data.pathAssets.map((a) => toTimelineAsset(a)));
}; };
</script> </script>

View File

@@ -4,16 +4,16 @@
import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte'; import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte'; import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
import AssetGrid from '$lib/components/photos-page/asset-grid.svelte'; import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte'; import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import { AppRoute } from '$lib/constants'; import { AppRoute } from '$lib/constants';
import { AssetStore } from '$lib/stores/assets-store.svelte';
import { onDestroy } from 'svelte';
import type { PageData } from './$types';
import { mdiPlus, mdiArrowLeft } from '@mdi/js';
import { t } from 'svelte-i18n';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { AssetStore } from '$lib/stores/assets-store.svelte';
import { mdiArrowLeft, mdiPlus } from '@mdi/js';
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
interface Props { interface Props {
data: PageData; data: PageData;

View File

@@ -33,20 +33,14 @@
import { AppRoute, PersonPageViewMode, QueryParameter, SessionStorageKey } from '$lib/constants'; import { AppRoute, PersonPageViewMode, QueryParameter, SessionStorageKey } from '$lib/constants';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { AssetStore } from '$lib/stores/assets-store.svelte'; import { AssetStore, type TimelineAsset } from '$lib/stores/assets-store.svelte';
import { locale } from '$lib/stores/preferences.store';
import { preferences } from '$lib/stores/user.store'; import { preferences } from '$lib/stores/user.store';
import { websocketEvents } from '$lib/stores/websocket'; import { websocketEvents } from '$lib/stores/websocket';
import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils'; import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { isExternalUrl } from '$lib/utils/navigation'; import { isExternalUrl } from '$lib/utils/navigation';
import { import { getPersonStatistics, mergePerson, searchPerson, updatePerson, type PersonResponseDto } from '@immich/sdk';
getPersonStatistics,
mergePerson,
searchPerson,
updatePerson,
type AssetResponseDto,
type PersonResponseDto,
} from '@immich/sdk';
import { import {
mdiAccountBoxOutline, mdiAccountBoxOutline,
mdiAccountMultipleCheckOutline, mdiAccountMultipleCheckOutline,
@@ -59,11 +53,10 @@
mdiHeartOutline, mdiHeartOutline,
mdiPlus, mdiPlus,
} from '@mdi/js'; } from '@mdi/js';
import { DateTime } from 'luxon';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { PageData } from './$types'; import type { PageData } from './$types';
import { locale } from '$lib/stores/preferences.store';
import { DateTime } from 'luxon';
interface Props { interface Props {
data: PageData; data: PageData;
@@ -202,7 +195,7 @@
data = { ...data, person }; data = { ...data, person };
}; };
const handleSelectFeaturePhoto = async (asset: AssetResponseDto) => { const handleSelectFeaturePhoto = async (asset: TimelineAsset) => {
if (viewMode !== PersonPageViewMode.SELECT_PERSON) { if (viewMode !== PersonPageViewMode.SELECT_PERSON) {
return; return;
} }

View File

@@ -32,7 +32,7 @@
type OnUnlink, type OnUnlink,
} from '$lib/utils/actions'; } from '$lib/utils/actions';
import { openFileUploadDialog } from '$lib/utils/file-uploader'; import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { AssetTypeEnum } from '@immich/sdk';
import { mdiDotsVertical, mdiPlus } from '@mdi/js'; import { mdiDotsVertical, mdiPlus } from '@mdi/js';
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@@ -50,8 +50,8 @@
const isLivePhoto = selectedAssets.length === 1 && !!selectedAssets[0].livePhotoVideoId; const isLivePhoto = selectedAssets.length === 1 && !!selectedAssets[0].livePhotoVideoId;
const isLivePhotoCandidate = const isLivePhotoCandidate =
selectedAssets.length === 2 && selectedAssets.length === 2 &&
selectedAssets.some((asset) => asset.type === AssetTypeEnum.Image) && selectedAssets.some((asset) => asset.isImage) &&
selectedAssets.some((asset) => asset.type === AssetTypeEnum.Video); selectedAssets.some((asset) => asset.isVideo);
return assetInteraction.isAllUserOwned && (isLivePhoto || isLivePhotoCandidate); return assetInteraction.isAllUserOwned && (isLivePhoto || isLivePhotoCandidate);
}); });

View File

@@ -1,28 +1,41 @@
<script lang="ts"> <script lang="ts">
import { afterNavigate, goto } from '$app/navigation'; import { afterNavigate, goto } from '$app/navigation';
import { page } from '$app/state'; import { page } from '$app/state';
import { shortcut } from '$lib/actions/shortcut';
import AlbumCardGroup from '$lib/components/album-page/album-card-group.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte'; import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte';
import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte'; import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte';
import AssetJobActions from '$lib/components/photos-page/actions/asset-job-actions.svelte';
import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte'; import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte';
import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte'; import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte';
import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte'; import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte'; import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte'; import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte'; import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte'; import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte'; import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
import { cancelMultiselect } from '$lib/utils/asset-utils'; import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte'; import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte';
import { AppRoute, QueryParameter } from '$lib/constants'; import { AppRoute, QueryParameter } from '$lib/constants';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { shortcut } from '$lib/actions/shortcut'; import type { TimelineAsset, Viewport } from '$lib/stores/assets-store.svelte';
import { lang, locale } from '$lib/stores/preferences.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { preferences } from '$lib/stores/user.store';
import { handlePromiseError } from '$lib/utils';
import { cancelMultiselect } from '$lib/utils/asset-utils';
import { parseUtcDate } from '$lib/utils/date-time';
import { handleError } from '$lib/utils/handle-error';
import { isAlbumsRoute, isPeopleRoute } from '$lib/utils/navigation';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { import {
type AlbumResponseDto, type AlbumResponseDto,
type AssetResponseDto,
getPerson, getPerson,
getTagById, getTagById,
type MetadataSearchDto, type MetadataSearchDto,
@@ -31,21 +44,8 @@
type SmartSearchDto, type SmartSearchDto,
} from '@immich/sdk'; } from '@immich/sdk';
import { mdiArrowLeft, mdiDotsVertical, mdiImageOffOutline, mdiPlus, mdiSelectAll } from '@mdi/js'; import { mdiArrowLeft, mdiDotsVertical, mdiImageOffOutline, mdiPlus, mdiSelectAll } from '@mdi/js';
import type { Viewport } from '$lib/stores/assets-store.svelte';
import { lang, locale } from '$lib/stores/preferences.store';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { handlePromiseError } from '$lib/utils';
import { parseUtcDate } from '$lib/utils/date-time';
import { featureFlags } from '$lib/stores/server-config.store';
import { handleError } from '$lib/utils/handle-error';
import AlbumCardGroup from '$lib/components/album-page/album-card-group.svelte';
import { isAlbumsRoute, isPeopleRoute } from '$lib/utils/navigation';
import { t } from 'svelte-i18n';
import { tick } from 'svelte'; import { tick } from 'svelte';
import AssetJobActions from '$lib/components/photos-page/actions/asset-job-actions.svelte'; import { t } from 'svelte-i18n';
import { preferences } from '$lib/stores/user.store';
import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
const MAX_ASSET_COUNT = 5000; const MAX_ASSET_COUNT = 5000;
let { isViewing: showAssetViewer } = assetViewingStore; let { isViewing: showAssetViewer } = assetViewingStore;
@@ -58,7 +58,7 @@
let nextPage = $state(1); let nextPage = $state(1);
let searchResultAlbums: AlbumResponseDto[] = $state([]); let searchResultAlbums: AlbumResponseDto[] = $state([]);
let searchResultAssets: AssetResponseDto[] = $state([]); let searchResultAssets: TimelineAsset[] = $state([]);
let isLoading = $state(true); let isLoading = $state(true);
let scrollY = $state(0); let scrollY = $state(0);
let scrollYHistory = 0; let scrollYHistory = 0;
@@ -122,7 +122,7 @@
const onAssetDelete = (assetIds: string[]) => { const onAssetDelete = (assetIds: string[]) => {
const assetIdSet = new Set(assetIds); const assetIdSet = new Set(assetIds);
searchResultAssets = searchResultAssets.filter((a: AssetResponseDto) => !assetIdSet.has(a.id)); searchResultAssets = searchResultAssets.filter((a: TimelineAsset) => !assetIdSet.has(a.id));
}; };
const handleSelectAll = () => { const handleSelectAll = () => {
assetInteraction.selectAssets(searchResultAssets); assetInteraction.selectAssets(searchResultAssets);
@@ -160,7 +160,7 @@
: await searchAssets({ metadataSearchDto: searchDto }); : await searchAssets({ metadataSearchDto: searchDto });
searchResultAlbums.push(...albums.items); searchResultAlbums.push(...albums.items);
searchResultAssets.push(...assets.items); searchResultAssets.push(...assets.items.map((a) => toTimelineAsset(a)));
nextPage = Number(assets.nextPage) || 0; nextPage = Number(assets.nextPage) || 0;
} catch (error) { } catch (error) {
@@ -238,7 +238,7 @@
if (terms.isNotInAlbum.toString() == 'true') { if (terms.isNotInAlbum.toString() == 'true') {
const assetIdSet = new Set(assetIds); const assetIdSet = new Set(assetIds);
searchResultAssets = searchResultAssets.filter((a: AssetResponseDto) => !assetIdSet.has(a.id)); searchResultAssets = searchResultAssets.filter((a) => !assetIdSet.has(a.id));
} }
}; };

View File

@@ -22,9 +22,9 @@
import { deleteTag, getAllTags, updateTag, upsertTags, type TagResponseDto } from '@immich/sdk'; import { deleteTag, getAllTags, updateTag, upsertTags, type TagResponseDto } from '@immich/sdk';
import { Button, HStack, Text } from '@immich/ui'; import { Button, HStack, Text } from '@immich/ui';
import { mdiPencil, mdiPlus, mdiTag, mdiTagMultiple, mdiTrashCanOutline } from '@mdi/js'; import { mdiPencil, mdiPlus, mdiTag, mdiTagMultiple, mdiTrashCanOutline } from '@mdi/js';
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { PageData } from './$types'; import type { PageData } from './$types';
import { onDestroy } from 'svelte';
interface Props { interface Props {
data: PageData; data: PageData;

View File

@@ -1,5 +1,6 @@
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
import { faker } from '@faker-js/faker'; import { faker } from '@faker-js/faker';
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk'; import { AssetTypeEnum, 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>({
@@ -25,3 +26,62 @@ export const assetFactory = Sync.makeFactory<AssetResponseDto>({
isOffline: Sync.each(() => faker.datatype.boolean()), isOffline: Sync.each(() => faker.datatype.boolean()),
hasMetadata: Sync.each(() => faker.datatype.boolean()), hasMetadata: Sync.each(() => faker.datatype.boolean()),
}); });
export const timelineAssetFactory = Sync.makeFactory<TimelineAsset>({
id: Sync.each(() => faker.string.uuid()),
ratio: Sync.each(() => faker.number.int()),
ownerId: Sync.each(() => faker.string.uuid()),
thumbhash: Sync.each(() => faker.string.alphanumeric(28)),
localDateTime: Sync.each(() => faker.date.past()),
isFavorite: Sync.each(() => faker.datatype.boolean()),
isArchived: false,
isTrashed: false,
isImage: true,
isVideo: false,
duration: '0:00:00.00000',
stack: null,
projectionType: null,
livePhotoVideoId: Sync.each(() => faker.string.uuid()),
city: faker.location.city(),
country: faker.location.country(),
people: [faker.person.fullName()],
});
export const toResponseDto = (...timelineAsset: TimelineAsset[]) => {
const bucketAssets: TimeBucketAssetResponseDto = {
city: [],
country: [],
duration: [],
id: [],
isArchived: [],
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.isArchived.push(asset.isArchived ? 1 : 0);
bucketAssets.isFavorite.push(asset.isFavorite ? 1 : 0);
bucketAssets.isImage.push(asset.isImage ? 1 : 0);
bucketAssets.isTrashed.push(asset.isTrashed ? 1 : 0);
bucketAssets.livePhotoVideoId.push(asset.livePhotoVideoId!);
bucketAssets.localDateTime.push(asset.localDateTime.toISOString());
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;
};