Compare commits

...

6 Commits

Author SHA1 Message Date
Timon
96b6165bd3 refactor(server)!: move correlationId to X-Correlation-ID response header (#28139) 2026-04-28 13:07:39 -04:00
Mees Frensel
2624f3884f fix(web): large files: better handling of asset deletions (#28117) 2026-04-28 18:18:39 +02:00
Timon
f9b7ce9407 fix(web): convert shared link expiry to UTC before serialising (#28135) 2026-04-28 16:10:08 +00:00
Timon
013ea37a0d refactor!: change number to integer types (#27912)
* refactor!: change number to integer types

* fix oversight
2026-04-28 11:25:03 -04:00
Mees Frensel
b2b4385271 chore(web): refactor people panel (#28136) 2026-04-28 11:22:22 -04:00
Mees Frensel
081c75bb21 fix(web): refresh memories hourly (#28114) 2026-04-28 11:18:51 -04:00
46 changed files with 415 additions and 332 deletions

View File

@@ -5,79 +5,66 @@ export const errorDto = {
error: 'Unauthorized',
statusCode: 401,
message: 'Authentication required',
correlationId: expect.any(String),
},
unauthorizedWithMessage: (message: string) => ({
error: 'Unauthorized',
statusCode: 401,
message,
correlationId: expect.any(String),
}),
forbidden: {
error: 'Forbidden',
statusCode: 403,
message: expect.any(String),
correlationId: expect.any(String),
},
missingPermission: (permission: string) => ({
error: 'Forbidden',
statusCode: 403,
message: `Missing required permission: ${permission}`,
correlationId: expect.any(String),
}),
wrongPassword: {
error: 'Bad Request',
statusCode: 400,
message: 'Wrong password',
correlationId: expect.any(String),
},
invalidToken: {
error: 'Unauthorized',
statusCode: 401,
message: 'Invalid user token',
correlationId: expect.any(String),
},
invalidShareKey: {
error: 'Unauthorized',
statusCode: 401,
message: 'Invalid share key',
correlationId: expect.any(String),
},
passwordRequired: {
error: 'Unauthorized',
statusCode: 401,
message: 'Password required',
correlationId: expect.any(String),
},
badRequest: (message: any = null) => ({
error: 'Bad Request',
statusCode: 400,
message: message ?? expect.anything(),
correlationId: expect.any(String),
}),
noPermission: {
error: 'Bad Request',
statusCode: 400,
message: expect.stringContaining('Not found or no'),
correlationId: expect.any(String),
},
incorrectLogin: {
error: 'Unauthorized',
statusCode: 401,
message: 'Incorrect email or password',
correlationId: expect.any(String),
},
alreadyHasAdmin: {
error: 'Bad Request',
statusCode: 400,
message: 'The server already has an admin',
correlationId: expect.any(String),
},
invalidEmail: {
error: 'Bad Request',
statusCode: 400,
message: ['email must be an email'],
correlationId: expect.any(String),
},
};

View File

@@ -183,15 +183,15 @@ class PeopleApi {
/// * [String] closestPersonId:
/// Closest person ID for similarity search
///
/// * [num] page:
/// * [int] page:
/// Page number for pagination
///
/// * [num] size:
/// * [int] size:
/// Number of items per page
///
/// * [bool] withHidden:
/// Include hidden people
Future<Response> getAllPeopleWithHttpInfo({ String? closestAssetId, String? closestPersonId, num? page, num? size, bool? withHidden, }) async {
Future<Response> getAllPeopleWithHttpInfo({ String? closestAssetId, String? closestPersonId, int? page, int? size, bool? withHidden, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/people';
@@ -244,15 +244,15 @@ class PeopleApi {
/// * [String] closestPersonId:
/// Closest person ID for similarity search
///
/// * [num] page:
/// * [int] page:
/// Page number for pagination
///
/// * [num] size:
/// * [int] size:
/// Number of items per page
///
/// * [bool] withHidden:
/// Include hidden people
Future<PeopleResponseDto?> getAllPeople({ String? closestAssetId, String? closestPersonId, num? page, num? size, bool? withHidden, }) async {
Future<PeopleResponseDto?> getAllPeople({ String? closestAssetId, String? closestPersonId, int? page, int? size, bool? withHidden, }) async {
final response = await getAllPeopleWithHttpInfo( closestAssetId: closestAssetId, closestPersonId: closestPersonId, page: page, size: size, withHidden: withHidden, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));

View File

@@ -404,10 +404,10 @@ class SearchApi {
/// * [List<String>] personIds:
/// Filter by person IDs
///
/// * [num] rating:
/// * [int] rating:
/// Filter by rating [1-5], or null for unrated
///
/// * [num] size:
/// * [int] size:
/// Number of results to return
///
/// * [String] state:
@@ -443,7 +443,7 @@ class SearchApi {
///
/// * [bool] withExif:
/// Include EXIF data in response
Future<Response> searchLargeAssetsWithHttpInfo({ List<String>? albumIds, String? city, String? country, DateTime? createdAfter, DateTime? createdBefore, bool? isEncoded, bool? isFavorite, bool? isMotion, bool? isNotInAlbum, bool? isOffline, String? lensModel, String? libraryId, String? make, int? minFileSize, String? model, String? ocr, List<String>? personIds, num? rating, num? size, String? state, List<String>? tagIds, DateTime? takenAfter, DateTime? takenBefore, DateTime? trashedAfter, DateTime? trashedBefore, AssetTypeEnum? type, DateTime? updatedAfter, DateTime? updatedBefore, AssetVisibility? visibility, bool? withDeleted, bool? withExif, }) async {
Future<Response> searchLargeAssetsWithHttpInfo({ List<String>? albumIds, String? city, String? country, DateTime? createdAfter, DateTime? createdBefore, bool? isEncoded, bool? isFavorite, bool? isMotion, bool? isNotInAlbum, bool? isOffline, String? lensModel, String? libraryId, String? make, int? minFileSize, String? model, String? ocr, List<String>? personIds, int? rating, int? size, String? state, List<String>? tagIds, DateTime? takenAfter, DateTime? takenBefore, DateTime? trashedAfter, DateTime? trashedBefore, AssetTypeEnum? type, DateTime? updatedAfter, DateTime? updatedBefore, AssetVisibility? visibility, bool? withDeleted, bool? withExif, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/search/large-assets';
@@ -619,10 +619,10 @@ class SearchApi {
/// * [List<String>] personIds:
/// Filter by person IDs
///
/// * [num] rating:
/// * [int] rating:
/// Filter by rating [1-5], or null for unrated
///
/// * [num] size:
/// * [int] size:
/// Number of results to return
///
/// * [String] state:
@@ -658,7 +658,7 @@ class SearchApi {
///
/// * [bool] withExif:
/// Include EXIF data in response
Future<List<AssetResponseDto>?> searchLargeAssets({ List<String>? albumIds, String? city, String? country, DateTime? createdAfter, DateTime? createdBefore, bool? isEncoded, bool? isFavorite, bool? isMotion, bool? isNotInAlbum, bool? isOffline, String? lensModel, String? libraryId, String? make, int? minFileSize, String? model, String? ocr, List<String>? personIds, num? rating, num? size, String? state, List<String>? tagIds, DateTime? takenAfter, DateTime? takenBefore, DateTime? trashedAfter, DateTime? trashedBefore, AssetTypeEnum? type, DateTime? updatedAfter, DateTime? updatedBefore, AssetVisibility? visibility, bool? withDeleted, bool? withExif, }) async {
Future<List<AssetResponseDto>?> searchLargeAssets({ List<String>? albumIds, String? city, String? country, DateTime? createdAfter, DateTime? createdBefore, bool? isEncoded, bool? isFavorite, bool? isMotion, bool? isNotInAlbum, bool? isOffline, String? lensModel, String? libraryId, String? make, int? minFileSize, String? model, String? ocr, List<String>? personIds, int? rating, int? size, String? state, List<String>? tagIds, DateTime? takenAfter, DateTime? takenBefore, DateTime? trashedAfter, DateTime? trashedBefore, AssetTypeEnum? type, DateTime? updatedAfter, DateTime? updatedBefore, AssetVisibility? visibility, bool? withDeleted, bool? withExif, }) async {
final response = await searchLargeAssetsWithHttpInfo( albumIds: albumIds, city: city, country: country, createdAfter: createdAfter, createdBefore: createdBefore, isEncoded: isEncoded, isFavorite: isFavorite, isMotion: isMotion, isNotInAlbum: isNotInAlbum, isOffline: isOffline, lensModel: lensModel, libraryId: libraryId, make: make, minFileSize: minFileSize, model: model, ocr: ocr, personIds: personIds, rating: rating, size: size, state: state, tagIds: tagIds, takenAfter: takenAfter, takenBefore: takenBefore, trashedAfter: trashedAfter, trashedBefore: trashedBefore, type: type, updatedAfter: updatedAfter, updatedBefore: updatedBefore, visibility: visibility, withDeleted: withDeleted, withExif: withExif, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));

View File

@@ -37,12 +37,15 @@ class AssetBulkUpdateDto {
/// Relative time offset in seconds
///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
///
/// 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.
///
num? dateTimeRelative;
int? dateTimeRelative;
/// Asset description
///
@@ -213,7 +216,7 @@ class AssetBulkUpdateDto {
return AssetBulkUpdateDto(
dateTimeOriginal: mapValueOfType<String>(json, r'dateTimeOriginal'),
dateTimeRelative: num.parse('${json[r'dateTimeRelative']}'),
dateTimeRelative: mapValueOfType<int>(json, r'dateTimeRelative'),
description: mapValueOfType<String>(json, r'description'),
duplicateId: mapValueOfType<String>(json, r'duplicateId'),
ids: json[r'ids'] is Iterable

View File

@@ -24,22 +24,26 @@ class AssetEditActionItemDtoParameters {
/// Height of the crop
///
/// Minimum value: 1
num height;
/// Maximum value: 9007199254740991
int height;
/// Width of the crop
///
/// Minimum value: 1
num width;
/// Maximum value: 9007199254740991
int width;
/// Top-Left X coordinate of crop
///
/// Minimum value: 0
num x;
/// Maximum value: 9007199254740991
int x;
/// Top-Left Y coordinate of crop
///
/// Minimum value: 0
num y;
/// Maximum value: 9007199254740991
int y;
/// Rotation angle in degrees
num angle;
@@ -88,10 +92,10 @@ class AssetEditActionItemDtoParameters {
final json = value.cast<String, dynamic>();
return AssetEditActionItemDtoParameters(
height: num.parse('${json[r'height']}'),
width: num.parse('${json[r'width']}'),
x: num.parse('${json[r'x']}'),
y: num.parse('${json[r'y']}'),
height: mapValueOfType<int>(json, r'height')!,
width: mapValueOfType<int>(json, r'width')!,
x: mapValueOfType<int>(json, r'x')!,
y: mapValueOfType<int>(json, r'y')!,
angle: num.parse('${json[r'angle']}'),
axis: MirrorAxis.fromJson(json[r'axis'])!,
);

View File

@@ -80,7 +80,8 @@ class AssetResponseDto {
/// Asset height
///
/// Minimum value: 0
num? height;
/// Maximum value: 9007199254740991
int? height;
/// Asset ID
String id;
@@ -165,7 +166,8 @@ class AssetResponseDto {
/// Asset width
///
/// Minimum value: 0
num? width;
/// Maximum value: 9007199254740991
int? width;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
@@ -346,9 +348,7 @@ class AssetResponseDto {
fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r'')!,
fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r'')!,
hasMetadata: mapValueOfType<bool>(json, r'hasMetadata')!,
height: json[r'height'] == null
? null
: num.parse('${json[r'height']}'),
height: mapValueOfType<int>(json, r'height'),
id: mapValueOfType<String>(json, r'id')!,
isArchived: mapValueOfType<bool>(json, r'isArchived')!,
isEdited: mapValueOfType<bool>(json, r'isEdited')!,
@@ -372,9 +372,7 @@ class AssetResponseDto {
unassignedFaces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'unassignedFaces']),
updatedAt: mapDateTime(json, r'updatedAt', r'')!,
visibility: AssetVisibility.fromJson(json[r'visibility'])!,
width: json[r'width'] == null
? null
: num.parse('${json[r'width']}'),
width: mapValueOfType<int>(json, r'width'),
);
}
return null;

View File

@@ -22,22 +22,26 @@ class CropParameters {
/// Height of the crop
///
/// Minimum value: 1
num height;
/// Maximum value: 9007199254740991
int height;
/// Width of the crop
///
/// Minimum value: 1
num width;
/// Maximum value: 9007199254740991
int width;
/// Top-Left X coordinate of crop
///
/// Minimum value: 0
num x;
/// Maximum value: 9007199254740991
int x;
/// Top-Left Y coordinate of crop
///
/// Minimum value: 0
num y;
/// Maximum value: 9007199254740991
int y;
@override
bool operator ==(Object other) => identical(this, other) || other is CropParameters &&
@@ -75,10 +79,10 @@ class CropParameters {
final json = value.cast<String, dynamic>();
return CropParameters(
height: num.parse('${json[r'height']}'),
width: num.parse('${json[r'width']}'),
x: num.parse('${json[r'x']}'),
y: num.parse('${json[r'y']}'),
height: mapValueOfType<int>(json, r'height')!,
width: mapValueOfType<int>(json, r'width')!,
x: mapValueOfType<int>(json, r'x')!,
y: mapValueOfType<int>(json, r'y')!,
);
}
return null;

View File

@@ -27,7 +27,8 @@ class DatabaseBackupConfig {
/// Keep last amount
///
/// Minimum value: 1
num keepLastAmount;
/// Maximum value: 9007199254740991
int keepLastAmount;
@override
bool operator ==(Object other) => identical(this, other) || other is DatabaseBackupConfig &&
@@ -64,7 +65,7 @@ class DatabaseBackupConfig {
return DatabaseBackupConfig(
cronExpression: mapValueOfType<String>(json, r'cronExpression')!,
enabled: mapValueOfType<bool>(json, r'enabled')!,
keepLastAmount: num.parse('${json[r'keepLastAmount']}'),
keepLastAmount: mapValueOfType<int>(json, r'keepLastAmount')!,
);
}
return null;

View File

@@ -22,7 +22,10 @@ class DatabaseBackupDto {
String filename;
/// Backup file size
num filesize;
///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int filesize;
/// Backup timezone
String timezone;
@@ -61,7 +64,7 @@ class DatabaseBackupDto {
return DatabaseBackupDto(
filename: mapValueOfType<String>(json, r'filename')!,
filesize: num.parse('${json[r'filesize']}'),
filesize: mapValueOfType<int>(json, r'filesize')!,
timezone: mapValueOfType<String>(json, r'timezone')!,
);
}

View File

@@ -52,12 +52,14 @@ class ExifResponseDto {
/// Image height in pixels
///
/// Minimum value: 0
num? exifImageHeight;
/// Maximum value: 9007199254740991
int? exifImageHeight;
/// Image width in pixels
///
/// Minimum value: 0
num? exifImageWidth;
/// Maximum value: 9007199254740991
int? exifImageWidth;
/// Exposure time
String? exposureTime;
@@ -75,7 +77,10 @@ class ExifResponseDto {
num? focalLength;
/// ISO sensitivity
num? iso;
///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int? iso;
/// GPS latitude
num? latitude;
@@ -102,7 +107,10 @@ class ExifResponseDto {
String? projectionType;
/// Rating
num? rating;
///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int? rating;
/// State/province name
String? state;
@@ -292,12 +300,8 @@ class ExifResponseDto {
country: mapValueOfType<String>(json, r'country'),
dateTimeOriginal: mapDateTime(json, r'dateTimeOriginal', r''),
description: mapValueOfType<String>(json, r'description'),
exifImageHeight: json[r'exifImageHeight'] == null
? null
: num.parse('${json[r'exifImageHeight']}'),
exifImageWidth: json[r'exifImageWidth'] == null
? null
: num.parse('${json[r'exifImageWidth']}'),
exifImageHeight: mapValueOfType<int>(json, r'exifImageHeight'),
exifImageWidth: mapValueOfType<int>(json, r'exifImageWidth'),
exposureTime: mapValueOfType<String>(json, r'exposureTime'),
fNumber: json[r'fNumber'] == null
? null
@@ -306,9 +310,7 @@ class ExifResponseDto {
focalLength: json[r'focalLength'] == null
? null
: num.parse('${json[r'focalLength']}'),
iso: json[r'iso'] == null
? null
: num.parse('${json[r'iso']}'),
iso: mapValueOfType<int>(json, r'iso'),
latitude: json[r'latitude'] == null
? null
: num.parse('${json[r'latitude']}'),
@@ -321,9 +323,7 @@ class ExifResponseDto {
modifyDate: mapDateTime(json, r'modifyDate', r''),
orientation: mapValueOfType<String>(json, r'orientation'),
projectionType: mapValueOfType<String>(json, r'projectionType'),
rating: json[r'rating'] == null
? null
: num.parse('${json[r'rating']}'),
rating: mapValueOfType<int>(json, r'rating'),
state: mapValueOfType<String>(json, r'state'),
timeZone: mapValueOfType<String>(json, r'timeZone'),
);

View File

@@ -21,9 +21,13 @@ class MachineLearningAvailabilityChecksDto {
/// Enabled
bool enabled;
num interval;
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int interval;
num timeout;
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int timeout;
@override
bool operator ==(Object other) => identical(this, other) || other is MachineLearningAvailabilityChecksDto &&
@@ -59,8 +63,8 @@ class MachineLearningAvailabilityChecksDto {
return MachineLearningAvailabilityChecksDto(
enabled: mapValueOfType<bool>(json, r'enabled')!,
interval: num.parse('${json[r'interval']}'),
timeout: num.parse('${json[r'timeout']}'),
interval: mapValueOfType<int>(json, r'interval')!,
timeout: mapValueOfType<int>(json, r'timeout')!,
);
}
return null;

View File

@@ -20,7 +20,10 @@ class MaintenanceDetectInstallStorageFolderDto {
});
/// Number of files in the folder
num files;
///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int files;
StorageFolder folder;
@@ -66,7 +69,7 @@ class MaintenanceDetectInstallStorageFolderDto {
final json = value.cast<String, dynamic>();
return MaintenanceDetectInstallStorageFolderDto(
files: num.parse('${json[r'files']}'),
files: mapValueOfType<int>(json, r'files')!,
folder: StorageFolder.fromJson(json[r'folder'])!,
readable: mapValueOfType<bool>(json, r'readable')!,
writable: mapValueOfType<bool>(json, r'writable')!,

View File

@@ -32,13 +32,15 @@ class MaintenanceStatusResponseDto {
///
String? error;
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
///
/// 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.
///
num? progress;
int? progress;
///
/// Please note: This property should have been non-nullable! Since the specification file
@@ -102,7 +104,7 @@ class MaintenanceStatusResponseDto {
action: MaintenanceAction.fromJson(json[r'action'])!,
active: mapValueOfType<bool>(json, r'active')!,
error: mapValueOfType<String>(json, r'error'),
progress: num.parse('${json[r'progress']}'),
progress: mapValueOfType<int>(json, r'progress'),
task: mapValueOfType<String>(json, r'task'),
);
}

View File

@@ -215,13 +215,14 @@ class MetadataSearchDto {
/// Page number
///
/// Minimum value: 1
/// Maximum value: 9007199254740991
///
/// 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.
///
num? page;
int? page;
/// Filter by person IDs
List<String> personIds;
@@ -239,7 +240,7 @@ class MetadataSearchDto {
///
/// Minimum value: -1
/// Maximum value: 5
num? rating;
int? rating;
/// Number of results to return
///
@@ -251,7 +252,7 @@ class MetadataSearchDto {
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
num? size;
int? size;
/// Filter by state/province name
String? state;
@@ -724,15 +725,13 @@ class MetadataSearchDto {
order: AssetOrder.fromJson(json[r'order']),
originalFileName: mapValueOfType<String>(json, r'originalFileName'),
originalPath: mapValueOfType<String>(json, r'originalPath'),
page: num.parse('${json[r'page']}'),
page: mapValueOfType<int>(json, r'page'),
personIds: json[r'personIds'] is Iterable
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
: const [],
previewPath: mapValueOfType<String>(json, r'previewPath'),
rating: json[r'rating'] == null
? null
: num.parse('${json[r'rating']}'),
size: num.parse('${json[r'size']}'),
rating: mapValueOfType<int>(json, r'rating'),
size: mapValueOfType<int>(json, r'size'),
state: mapValueOfType<String>(json, r'state'),
tagIds: json[r'tagIds'] is Iterable
? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false)

View File

@@ -147,7 +147,7 @@ class RandomSearchDto {
///
/// Minimum value: -1
/// Maximum value: 5
num? rating;
int? rating;
/// Number of results to return
///
@@ -159,7 +159,7 @@ class RandomSearchDto {
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
num? size;
int? size;
/// Filter by state/province name
String? state;
@@ -549,10 +549,8 @@ class RandomSearchDto {
personIds: json[r'personIds'] is Iterable
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
: const [],
rating: json[r'rating'] == null
? null
: num.parse('${json[r'rating']}'),
size: num.parse('${json[r'size']}'),
rating: mapValueOfType<int>(json, r'rating'),
size: mapValueOfType<int>(json, r'size'),
state: mapValueOfType<String>(json, r'state'),
tagIds: json[r'tagIds'] is Iterable
? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false)

View File

@@ -39,13 +39,14 @@ class SessionCreateDto {
/// Session duration in seconds
///
/// Minimum value: 1
/// Maximum value: 9007199254740991
///
/// 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.
///
num? duration;
int? duration;
@override
bool operator ==(Object other) => identical(this, other) || other is SessionCreateDto &&
@@ -94,7 +95,7 @@ class SessionCreateDto {
return SessionCreateDto(
deviceOS: mapValueOfType<String>(json, r'deviceOS'),
deviceType: mapValueOfType<String>(json, r'deviceType'),
duration: num.parse('${json[r'duration']}'),
duration: mapValueOfType<int>(json, r'duration'),
);
}
return null;

View File

@@ -154,13 +154,14 @@ class SmartSearchDto {
/// Page number
///
/// Minimum value: 1
/// Maximum value: 9007199254740991
///
/// 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.
///
num? page;
int? page;
/// Filter by person IDs
List<String> personIds;
@@ -187,7 +188,7 @@ class SmartSearchDto {
///
/// Minimum value: -1
/// Maximum value: 5
num? rating;
int? rating;
/// Number of results to return
///
@@ -199,7 +200,7 @@ class SmartSearchDto {
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
num? size;
int? size;
/// Filter by state/province name
String? state;
@@ -583,16 +584,14 @@ class SmartSearchDto {
make: mapValueOfType<String>(json, r'make'),
model: mapValueOfType<String>(json, r'model'),
ocr: mapValueOfType<String>(json, r'ocr'),
page: num.parse('${json[r'page']}'),
page: mapValueOfType<int>(json, r'page'),
personIds: json[r'personIds'] is Iterable
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
: const [],
query: mapValueOfType<String>(json, r'query'),
queryAssetId: mapValueOfType<String>(json, r'queryAssetId'),
rating: json[r'rating'] == null
? null
: num.parse('${json[r'rating']}'),
size: num.parse('${json[r'size']}'),
rating: mapValueOfType<int>(json, r'rating'),
size: mapValueOfType<int>(json, r'size'),
state: mapValueOfType<String>(json, r'state'),
tagIds: json[r'tagIds'] is Iterable
? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false)

View File

@@ -152,7 +152,7 @@ class StatisticsSearchDto {
///
/// Minimum value: -1
/// Maximum value: 5
num? rating;
int? rating;
/// Filter by state/province name
String? state;
@@ -479,9 +479,7 @@ class StatisticsSearchDto {
personIds: json[r'personIds'] is Iterable
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
: const [],
rating: json[r'rating'] == null
? null
: num.parse('${json[r'rating']}'),
rating: mapValueOfType<int>(json, r'rating'),
state: mapValueOfType<String>(json, r'state'),
tagIds: json[r'tagIds'] is Iterable
? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false)

View File

@@ -57,7 +57,8 @@ class SystemConfigOAuthDto {
/// Default storage quota
///
/// Minimum value: 0
num? defaultStorageQuota;
/// Maximum value: 9007199254740991
int? defaultStorageQuota;
/// Enabled
bool enabled;
@@ -200,9 +201,7 @@ class SystemConfigOAuthDto {
buttonText: mapValueOfType<String>(json, r'buttonText')!,
clientId: mapValueOfType<String>(json, r'clientId')!,
clientSecret: mapValueOfType<String>(json, r'clientSecret')!,
defaultStorageQuota: json[r'defaultStorageQuota'] == null
? null
: num.parse('${json[r'defaultStorageQuota']}'),
defaultStorageQuota: mapValueOfType<int>(json, r'defaultStorageQuota'),
enabled: mapValueOfType<bool>(json, r'enabled')!,
endSessionEndpoint: mapValueOfType<String>(json, r'endSessionEndpoint')!,
issuerUrl: mapValueOfType<String>(json, r'issuerUrl')!,

View File

@@ -34,7 +34,7 @@ class SystemConfigSmtpTransportDto {
///
/// Minimum value: 0
/// Maximum value: 65535
num port;
int port;
/// Whether to use secure connection (TLS/SSL)
bool secure;
@@ -87,7 +87,7 @@ class SystemConfigSmtpTransportDto {
host: mapValueOfType<String>(json, r'host')!,
ignoreCert: mapValueOfType<bool>(json, r'ignoreCert')!,
password: mapValueOfType<String>(json, r'password')!,
port: num.parse('${json[r'port']}'),
port: mapValueOfType<int>(json, r'port')!,
secure: mapValueOfType<bool>(json, r'secure')!,
username: mapValueOfType<String>(json, r'username')!,
);

View File

@@ -26,7 +26,10 @@ class WorkflowActionResponseDto {
String id;
/// Action order
num order;
///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int order;
/// Plugin action ID
String pluginActionId;
@@ -79,7 +82,7 @@ class WorkflowActionResponseDto {
return WorkflowActionResponseDto(
actionConfig: mapCastOfType<String, Object>(json, r'actionConfig'),
id: mapValueOfType<String>(json, r'id')!,
order: num.parse('${json[r'order']}'),
order: mapValueOfType<int>(json, r'order')!,
pluginActionId: mapValueOfType<String>(json, r'pluginActionId')!,
workflowId: mapValueOfType<String>(json, r'workflowId')!,
);

View File

@@ -26,7 +26,10 @@ class WorkflowFilterResponseDto {
String id;
/// Filter order
num order;
///
/// Minimum value: -9007199254740991
/// Maximum value: 9007199254740991
int order;
/// Plugin filter ID
String pluginFilterId;
@@ -79,7 +82,7 @@ class WorkflowFilterResponseDto {
return WorkflowFilterResponseDto(
filterConfig: mapCastOfType<String, Object>(json, r'filterConfig'),
id: mapValueOfType<String>(json, r'id')!,
order: num.parse('${json[r'order']}'),
order: mapValueOfType<int>(json, r'order')!,
pluginFilterId: mapValueOfType<String>(json, r'pluginFilterId')!,
workflowId: mapValueOfType<String>(json, r'workflowId')!,
);

View File

@@ -7964,8 +7964,9 @@
"description": "Page number for pagination",
"schema": {
"minimum": 1,
"maximum": 9007199254740991,
"default": 1,
"type": "number"
"type": "integer"
}
},
{
@@ -7977,7 +7978,7 @@
"minimum": 1,
"maximum": 1000,
"default": 500,
"type": "number"
"type": "integer"
}
},
{
@@ -9372,7 +9373,7 @@
],
"x-immich-state": "Stable",
"schema": {
"type": "number",
"type": "integer",
"minimum": -1,
"maximum": 5,
"nullable": true
@@ -9386,7 +9387,7 @@
"schema": {
"minimum": 1,
"maximum": 1000,
"type": "number"
"type": "integer"
}
},
{
@@ -15636,7 +15637,9 @@
},
"dateTimeRelative": {
"description": "Relative time offset in seconds",
"type": "number"
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
},
"description": {
"description": "Asset description",
@@ -16650,9 +16653,10 @@
},
"height": {
"description": "Asset height",
"maximum": 9007199254740991,
"minimum": 0,
"nullable": true,
"type": "number"
"type": "integer"
},
"id": {
"description": "Asset ID",
@@ -16795,9 +16799,10 @@
},
"width": {
"description": "Asset width",
"maximum": 9007199254740991,
"minimum": 0,
"nullable": true,
"type": "number"
"type": "integer"
}
},
"required": [
@@ -17214,23 +17219,27 @@
"properties": {
"height": {
"description": "Height of the crop",
"maximum": 9007199254740991,
"minimum": 1,
"type": "number"
"type": "integer"
},
"width": {
"description": "Width of the crop",
"maximum": 9007199254740991,
"minimum": 1,
"type": "number"
"type": "integer"
},
"x": {
"description": "Top-Left X coordinate of crop",
"maximum": 9007199254740991,
"minimum": 0,
"type": "number"
"type": "integer"
},
"y": {
"description": "Top-Left Y coordinate of crop",
"maximum": 9007199254740991,
"minimum": 0,
"type": "number"
"type": "integer"
}
},
"required": [
@@ -17254,8 +17263,9 @@
},
"keepLastAmount": {
"description": "Keep last amount",
"maximum": 9007199254740991,
"minimum": 1,
"type": "number"
"type": "integer"
}
},
"required": [
@@ -17288,7 +17298,9 @@
},
"filesize": {
"description": "Backup file size",
"type": "number"
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
},
"timezone": {
"description": "Backup timezone",
@@ -17627,16 +17639,18 @@
"exifImageHeight": {
"default": null,
"description": "Image height in pixels",
"maximum": 9007199254740991,
"minimum": 0,
"nullable": true,
"type": "number"
"type": "integer"
},
"exifImageWidth": {
"default": null,
"description": "Image width in pixels",
"maximum": 9007199254740991,
"minimum": 0,
"nullable": true,
"type": "number"
"type": "integer"
},
"exposureTime": {
"default": null,
@@ -17667,8 +17681,10 @@
"iso": {
"default": null,
"description": "ISO sensitivity",
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"nullable": true,
"type": "number"
"type": "integer"
},
"latitude": {
"default": null,
@@ -17722,8 +17738,10 @@
"rating": {
"default": null,
"description": "Rating",
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"nullable": true,
"type": "number"
"type": "integer"
},
"state": {
"default": null,
@@ -18150,10 +18168,14 @@
"type": "boolean"
},
"interval": {
"type": "number"
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
},
"timeout": {
"type": "number"
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
}
},
"required": [
@@ -18203,7 +18225,9 @@
"properties": {
"files": {
"description": "Number of files in the folder",
"type": "number"
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
},
"folder": {
"$ref": "#/components/schemas/StorageFolder"
@@ -18246,7 +18270,9 @@
"type": "string"
},
"progress": {
"type": "number"
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
},
"task": {
"type": "string"
@@ -18723,8 +18749,9 @@
},
"page": {
"description": "Page number",
"maximum": 9007199254740991,
"minimum": 1,
"type": "number"
"type": "integer"
},
"personIds": {
"description": "Filter by person IDs",
@@ -18744,7 +18771,7 @@
"maximum": 5,
"minimum": -1,
"nullable": true,
"type": "number",
"type": "integer",
"x-immich-history": [
{
"version": "v1",
@@ -18766,7 +18793,7 @@
"description": "Number of results to return",
"maximum": 1000,
"minimum": 1,
"type": "number"
"type": "integer"
},
"state": {
"description": "Filter by state/province name",
@@ -20597,7 +20624,7 @@
"maximum": 5,
"minimum": -1,
"nullable": true,
"type": "number",
"type": "integer",
"x-immich-history": [
{
"version": "v1",
@@ -20619,7 +20646,7 @@
"description": "Number of results to return",
"maximum": 1000,
"minimum": 1,
"type": "number"
"type": "integer"
},
"state": {
"description": "Filter by state/province name",
@@ -21437,8 +21464,9 @@
},
"duration": {
"description": "Session duration in seconds",
"maximum": 9007199254740991,
"minimum": 1,
"type": "number"
"type": "integer"
}
},
"type": "object"
@@ -21952,8 +21980,9 @@
},
"page": {
"description": "Page number",
"maximum": 9007199254740991,
"minimum": 1,
"type": "number"
"type": "integer"
},
"personIds": {
"description": "Filter by person IDs",
@@ -21979,7 +22008,7 @@
"maximum": 5,
"minimum": -1,
"nullable": true,
"type": "number",
"type": "integer",
"x-immich-history": [
{
"version": "v1",
@@ -22001,7 +22030,7 @@
"description": "Number of results to return",
"maximum": 1000,
"minimum": 1,
"type": "number"
"type": "integer"
},
"state": {
"description": "Filter by state/province name",
@@ -22239,7 +22268,7 @@
"maximum": 5,
"minimum": -1,
"nullable": true,
"type": "number",
"type": "integer",
"x-immich-history": [
{
"version": "v1",
@@ -24371,9 +24400,10 @@
},
"defaultStorageQuota": {
"description": "Default storage quota",
"maximum": 9007199254740991,
"minimum": 0,
"nullable": true,
"type": "number"
"type": "integer"
},
"enabled": {
"description": "Enabled",
@@ -24548,7 +24578,7 @@
"description": "SMTP server port",
"maximum": 65535,
"minimum": 0,
"type": "number"
"type": "integer"
},
"secure": {
"description": "Whether to use secure connection (TLS/SSL)",
@@ -25966,7 +25996,9 @@
},
"order": {
"description": "Action order",
"type": "number"
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
},
"pluginActionId": {
"description": "Plugin action ID",
@@ -26065,7 +26097,9 @@
},
"order": {
"description": "Filter order",
"type": "number"
"maximum": 9007199254740991,
"minimum": -9007199254740991,
"type": "integer"
},
"pluginFilterId": {
"description": "Plugin filter ID",

View File

@@ -49,7 +49,7 @@ describe(SearchController.name, () => {
});
it('should reject an invalid size', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ size: -1.5 });
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ size: -1 });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['[size] Too small: expected number to be >=1']));
});

View File

@@ -50,8 +50,8 @@ const SanitizedAssetResponseSchema = z
duration: z.string().nullable().describe('Video/gif duration in hh:mm:ss.SSS format (null for static images)'),
livePhotoVideoId: z.string().nullish().describe('Live photo video ID'),
hasMetadata: z.boolean().describe('Whether asset has metadata'),
width: z.number().min(0).nullable().describe('Asset width'),
height: z.number().min(0).nullable().describe('Asset height'),
width: z.int().min(0).nullable().describe('Asset width'),
height: z.int().min(0).nullable().describe('Asset height'),
})
.meta({ id: 'SanitizedAssetResponseDto' });

View File

@@ -40,7 +40,7 @@ const UpdateAssetBaseSchema = z
const AssetBulkUpdateBaseSchema = UpdateAssetBaseSchema.extend({
ids: z.array(z.uuidv4()).describe('Asset IDs to update'),
duplicateId: z.string().nullish().describe('Duplicate ID'),
dateTimeRelative: z.number().optional().describe('Relative time offset in seconds'),
dateTimeRelative: z.int().optional().describe('Relative time offset in seconds'),
timeZone: z.string().optional().describe('Time zone (IANA timezone)'),
});

View File

@@ -4,7 +4,7 @@ import z from 'zod';
const DatabaseBackupSchema = z
.object({
filename: z.string().describe('Backup filename'),
filesize: z.number().describe('Backup file size'),
filesize: z.int().describe('Backup file size'),
timezone: z.string().describe('Backup timezone'),
})
.meta({ id: 'DatabaseBackupDto' });

View File

@@ -21,10 +21,10 @@ const MirrorAxisSchema = z.enum(['horizontal', 'vertical']).describe('Axis to mi
const CropParametersSchema = z
.object({
x: z.number().min(0).describe('Top-Left X coordinate of crop'),
y: z.number().min(0).describe('Top-Left Y coordinate of crop'),
width: z.number().min(1).describe('Width of the crop'),
height: z.number().min(1).describe('Height of the crop'),
x: z.int().min(0).describe('Top-Left X coordinate of crop'),
y: z.int().min(0).describe('Top-Left Y coordinate of crop'),
width: z.int().min(1).describe('Width of the crop'),
height: z.int().min(1).describe('Height of the crop'),
})
.meta({ id: 'CropParameters' });

View File

@@ -8,8 +8,8 @@ export const ExifResponseSchema = z
.object({
make: z.string().nullish().default(null).describe('Camera make'),
model: z.string().nullish().default(null).describe('Camera model'),
exifImageWidth: z.number().min(0).nullish().default(null).describe('Image width in pixels'),
exifImageHeight: z.number().min(0).nullish().default(null).describe('Image height in pixels'),
exifImageWidth: z.int().min(0).nullish().default(null).describe('Image width in pixels'),
exifImageHeight: z.int().min(0).nullish().default(null).describe('Image height in pixels'),
fileSizeInByte: z.int().min(0).nullish().default(null).describe('File size in bytes'),
orientation: z.string().nullish().default(null).describe('Image orientation'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
@@ -20,7 +20,7 @@ export const ExifResponseSchema = z
lensModel: z.string().nullish().default(null).describe('Lens model'),
fNumber: z.number().nullish().default(null).describe('F-number (aperture)'),
focalLength: z.number().nullish().default(null).describe('Focal length in mm'),
iso: z.number().nullish().default(null).describe('ISO sensitivity'),
iso: z.int().nullish().default(null).describe('ISO sensitivity'),
exposureTime: z.string().nullish().default(null).describe('Exposure time'),
latitude: z.number().nullish().default(null).describe('GPS latitude'),
longitude: z.number().nullish().default(null).describe('GPS longitude'),
@@ -29,7 +29,7 @@ export const ExifResponseSchema = z
country: z.string().nullish().default(null).describe('Country name'),
description: z.string().nullish().default(null).describe('Image description'),
projectionType: z.string().nullish().default(null).describe('Projection type'),
rating: z.number().nullish().default(null).describe('Rating'),
rating: z.int().nullish().default(null).describe('Rating'),
})
.describe('EXIF response')
.meta({ id: 'ExifResponseDto' });

View File

@@ -29,7 +29,7 @@ const MaintenanceStatusResponseSchema = z
.object({
active: z.boolean(),
action: MaintenanceActionSchema,
progress: z.number().optional(),
progress: z.int().optional(),
task: z.string().optional(),
error: z.string().optional(),
})
@@ -40,7 +40,7 @@ const MaintenanceDetectInstallStorageFolderSchema = z
folder: StorageFolderSchema,
readable: z.boolean().describe('Whether the folder is readable'),
writable: z.boolean().describe('Whether the folder is writable'),
files: z.number().describe('Number of files in the folder'),
files: z.int().describe('Number of files in the folder'),
})
.meta({ id: 'MaintenanceDetectInstallStorageFolderDto' });

View File

@@ -51,8 +51,8 @@ const PersonSearchSchema = z
withHidden: stringToBool.optional().describe('Include hidden people'),
closestPersonId: z.uuidv4().optional().describe('Closest person ID for similarity search'),
closestAssetId: z.uuidv4().optional().describe('Closest asset ID for similarity search'),
page: z.coerce.number().min(1).default(1).describe('Page number for pagination'),
size: z.coerce.number().min(1).max(1000).default(500).describe('Number of items per page'),
page: z.coerce.number().int().min(1).default(1).describe('Page number for pagination'),
size: z.coerce.number().int().min(1).max(1000).default(500).describe('Number of items per page'),
})
.meta({ id: 'PersonSearchDto' });

View File

@@ -34,7 +34,7 @@ const BaseSearchSchema = z.object({
tagIds: z.array(z.uuidv4()).nullish().describe('Filter by tag IDs'),
albumIds: z.array(z.uuidv4()).optional().describe('Filter by album IDs'),
rating: z
.number()
.int()
.min(-1)
.max(5)
.nullish()
@@ -52,7 +52,7 @@ const BaseSearchSchema = z.object({
const BaseSearchWithResultsSchema = BaseSearchSchema.extend({
withDeleted: z.boolean().optional().describe('Include deleted assets'),
withExif: z.boolean().optional().describe('Include EXIF data in response'),
size: z.number().min(1).max(1000).optional().describe('Number of results to return'),
size: z.int().min(1).max(1000).optional().describe('Number of results to return'),
});
const RandomSearchSchema = BaseSearchWithResultsSchema.extend({
@@ -62,7 +62,7 @@ const RandomSearchSchema = BaseSearchWithResultsSchema.extend({
const LargeAssetSearchSchema = BaseSearchWithResultsSchema.extend({
minFileSize: z.coerce.number().int().min(0).optional().describe('Minimum file size in bytes'),
size: z.coerce.number().min(1).max(1000).optional().describe('Number of results to return'),
size: z.coerce.number().int().min(1).max(1000).optional().describe('Number of results to return'),
}).meta({ id: 'LargeAssetSearchDto' });
const MetadataSearchSchema = RandomSearchSchema.extend({
@@ -75,7 +75,7 @@ const MetadataSearchSchema = RandomSearchSchema.extend({
thumbnailPath: z.string().optional().describe('Filter by thumbnail file path'),
encodedVideoPath: z.string().optional().describe('Filter by encoded video file path'),
order: AssetOrderSchema.default(AssetOrder.Desc).optional().describe('Sort order'),
page: z.number().min(1).optional().describe('Page number'),
page: z.int().min(1).optional().describe('Page number'),
}).meta({ id: 'MetadataSearchDto' });
const StatisticsSearchSchema = BaseSearchSchema.extend({
@@ -86,7 +86,7 @@ const SmartSearchSchema = BaseSearchWithResultsSchema.extend({
query: z.string().trim().optional().describe('Natural language search query'),
queryAssetId: z.uuidv4().optional().describe('Asset ID to use as search reference'),
language: z.string().optional().describe('Search language code'),
page: z.number().min(1).optional().describe('Page number'),
page: z.int().min(1).optional().describe('Page number'),
}).meta({ id: 'SmartSearchDto' });
const SearchPlacesSchema = z

View File

@@ -4,7 +4,7 @@ import z from 'zod';
const SessionCreateSchema = z
.object({
duration: z.number().min(1).optional().describe('Session duration in seconds'),
duration: z.int().min(1).optional().describe('Session duration in seconds'),
deviceType: z.string().optional().describe('Device type'),
deviceOS: z.string().optional().describe('Device OS'),
})

View File

@@ -51,7 +51,7 @@ const DatabaseBackupSchema = z
.object({
enabled: configBool.describe('Enabled'),
cronExpression: cronExpressionSchema,
keepLastAmount: z.number().min(1).describe('Keep last amount'),
keepLastAmount: z.int().min(1).describe('Keep last amount'),
})
.meta({ id: 'DatabaseBackupConfig' });
@@ -130,8 +130,8 @@ const SystemConfigLoggingSchema = z
const MachineLearningAvailabilityChecksSchema = z
.object({
enabled: configBool.describe('Enabled'),
timeout: z.number(),
interval: z.number(),
timeout: z.int(),
interval: z.int(),
})
.meta({ id: 'MachineLearningAvailabilityChecksDto' });
@@ -180,7 +180,7 @@ const SystemConfigOAuthSchema = z
tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethodSchema,
timeout: z.int().min(1).describe('Timeout'),
allowInsecureRequests: configBool.describe('Allow insecure requests'),
defaultStorageQuota: z.number().min(0).nullable().describe('Default storage quota'),
defaultStorageQuota: z.int().min(0).nullable().describe('Default storage quota'),
enabled: configBool.describe('Enabled'),
issuerUrl: z
.string()
@@ -254,7 +254,7 @@ const SystemConfigSmtpTransportSchema = z
.object({
ignoreCert: configBool.describe('Whether to ignore SSL certificate errors'),
host: z.string().describe('SMTP server hostname'),
port: z.number().min(0).max(65_535).describe('SMTP server port'),
port: z.int().min(0).max(65_535).describe('SMTP server port'),
secure: configBool.describe('Whether to use secure connection (TLS/SSL)'),
username: z.string().describe('SMTP username'),
password: z.string().describe('SMTP password'),

View File

@@ -46,7 +46,7 @@ const WorkflowFilterResponseSchema = z
workflowId: z.string().describe('Workflow ID'),
pluginFilterId: z.string().describe('Plugin filter ID'),
filterConfig: FilterConfigSchema.nullable(),
order: z.number().describe('Filter order'),
order: z.int().describe('Filter order'),
})
.meta({ id: 'WorkflowFilterResponseDto' });
@@ -56,7 +56,7 @@ const WorkflowActionResponseSchema = z
workflowId: z.string().describe('Workflow ID'),
pluginActionId: z.string().describe('Plugin action ID'),
actionConfig: ActionConfigSchema.nullable(),
order: z.number().describe('Action order'),
order: z.int().describe('Action order'),
})
.meta({ id: 'WorkflowActionResponseDto' });

View File

@@ -22,7 +22,6 @@ export enum ImmichHeader {
SharedLinkKey = 'x-immich-share-key',
SharedLinkSlug = 'x-immich-share-slug',
Checksum = 'x-immich-checksum',
Cid = 'x-immich-cid',
}
export enum ImmichQuery {

View File

@@ -20,14 +20,16 @@ export class GlobalExceptionFilter implements ExceptionFilter<Error> {
const response = ctx.getResponse<Response>();
const { status, body } = this.fromError(error);
if (!response.headersSent) {
response.status(status).json({ ...body, statusCode: status, correlationId: this.cls.getId() });
response.header('X-Correlation-ID', this.cls.getId());
response.status(status).json({ ...body, statusCode: status });
}
}
handleError(res: Response, error: Error) {
const { status, body } = this.fromError(error);
if (!res.headersSent) {
res.status(status).json({ ...body, statusCode: status, correlationId: this.cls.getId() });
res.header('X-Correlation-ID', this.cls.getId());
res.status(status).json({ ...body, statusCode: status });
}
}

View File

@@ -15,7 +15,6 @@ import { EnvSchema } from 'src/dtos/env.dto';
import {
DatabaseExtension,
ImmichEnvironment,
ImmichHeader,
ImmichTelemetry,
ImmichWorker,
LogFormat,
@@ -301,11 +300,11 @@ const getEnv = (): EnvData => {
mount: true,
generateId: true,
setup: (cls, req: Request, res: Response) => {
const headerValues = req.headers[ImmichHeader.Cid];
const headerValues = req.headers['x-correlation-id'];
const headerValue = Array.isArray(headerValues) ? headerValues[0] : headerValues;
const cid = headerValue || cls.get(CLS_ID);
cls.set(CLS_ID, cid);
res.header(ImmichHeader.Cid, cid);
res.header('X-Correlation-ID', cid);
},
},
},

View File

@@ -5,43 +5,36 @@ export const errorDto = {
error: 'Unauthorized',
statusCode: 401,
message: 'Authentication required',
correlationId: expect.any(String),
},
forbidden: {
error: 'Forbidden',
statusCode: 403,
message: expect.any(String),
correlationId: expect.any(String),
},
missingPermission: (permission: string) => ({
error: 'Forbidden',
statusCode: 403,
message: `Missing required permission: ${permission}`,
correlationId: expect.any(String),
}),
wrongPassword: {
error: 'Bad Request',
statusCode: 400,
message: 'Wrong password',
correlationId: expect.any(String),
},
invalidToken: {
error: 'Unauthorized',
statusCode: 401,
message: 'Invalid user token',
correlationId: expect.any(String),
},
invalidShareKey: {
error: 'Unauthorized',
statusCode: 401,
message: 'Invalid share key',
correlationId: expect.any(String),
},
invalidSharePassword: {
error: 'Unauthorized',
statusCode: 401,
message: 'Invalid password',
correlationId: expect.any(String),
},
badRequest: (message: any = null) => ({
error: 'Bad Request',
@@ -52,18 +45,15 @@ export const errorDto = {
error: 'Bad Request',
statusCode: 400,
message: expect.stringContaining('Not found or no'),
correlationId: expect.any(String),
},
incorrectLogin: {
error: 'Unauthorized',
statusCode: 401,
message: 'Incorrect email or password',
correlationId: expect.any(String),
},
alreadyHasAdmin: {
error: 'Bad Request',
statusCode: 400,
message: 'The server already has an admin',
correlationId: expect.any(String),
},
};

View File

@@ -35,7 +35,7 @@
const setSelectedDate = (value: DateTime | undefined) => {
selectedPresetValue = null; // Clear preset when manually setting date
expiresAt = value ? value.toISO() : null;
expiresAt = value ? value.toUTC().toISO() : null;
};
const selectPreset = (value: number) => {
@@ -44,8 +44,8 @@
expiresAt = null;
return;
}
const newDate = DateTime.now().plus(value);
expiresAt = newDate.toISO();
const newDate = DateTime.now().plus({ milliseconds: value });
expiresAt = newDate.toUTC().toISO();
};
const isSelected = (value: number) => {

View File

@@ -11,7 +11,7 @@
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { Route } from '$lib/route';
import { locale } from '$lib/stores/preferences.store';
import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils';
import { getAssetMediaUrl } from '$lib/utils';
import { delay, getDimensions } from '$lib/utils/asset-utils';
import { getByteUnitString } from '$lib/utils/byte-units';
import { handleError } from '$lib/utils/handle-error';
@@ -24,26 +24,15 @@
type AssetResponseDto,
} from '@immich/sdk';
import { Icon, IconButton, LoadingSpinner, Text } from '@immich/ui';
import {
mdiCamera,
mdiCameraIris,
mdiClose,
mdiEye,
mdiEyeOff,
mdiImageOutline,
mdiInformationOutline,
mdiPencil,
mdiPlus,
} from '@mdi/js';
import { DateTime } from 'luxon';
import { mdiCamera, mdiCameraIris, mdiClose, mdiImageOutline, mdiInformationOutline } from '@mdi/js';
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
import { slide } from 'svelte/transition';
import ImageThumbnail from '../assets/thumbnail/ImageThumbnail.svelte';
import PersonSidePanel from '../faces-page/PersonSidePanel.svelte';
import OnEvents from '../OnEvents.svelte';
import UserAvatar from '../shared-components/UserAvatar.svelte';
import AlbumListItemDetails from './AlbumListItemDetails.svelte';
import DetailPanelPeople from '$lib/components/asset-viewer/DetailPanelPeople.svelte';
interface Props {
asset: AssetResponseDto;
@@ -53,8 +42,6 @@
let { asset, currentAlbum = null }: Props = $props();
let isOwner = $derived(authManager.authenticated && authManager.user.id === asset.ownerId);
let people = $derived(asset.people || []);
let unassignedFaces = $derived(asset.unassignedFaces || []);
let latlng = $derived(
(() => {
const lat = asset.exifInfo?.latitude;
@@ -162,110 +149,7 @@
<DetailPanelDescription {asset} {isOwner} />
<DetailPanelRating {asset} {isOwner} />
{#if !authManager.isSharedLink && isOwner}
<section class="px-4 pt-4 text-sm">
<div class="flex h-10 w-full items-center justify-between">
<Text size="small" color="muted">{$t('people')}</Text>
<div class="flex gap-2 items-center">
{#if people.some((person) => person.isHidden)}
<IconButton
aria-label={$t('show_hidden_people')}
icon={assetViewerManager.isShowingHiddenPeople ? mdiEyeOff : mdiEye}
size="medium"
shape="round"
color="secondary"
variant="ghost"
onclick={() => assetViewerManager.toggleHiddenPeople()}
/>
{/if}
<IconButton
aria-label={$t('tag_people')}
icon={mdiPlus}
size="medium"
shape="round"
color="secondary"
variant="ghost"
onclick={() => assetViewerManager.toggleFaceEditMode()}
/>
{#if people.length > 0 || unassignedFaces.length > 0}
<IconButton
aria-label={$t('edit_people')}
icon={mdiPencil}
size="medium"
shape="round"
color="secondary"
variant="ghost"
onclick={() => assetViewerManager.openEditFacesPanel()}
/>
{/if}
</div>
</div>
<div class="mt-2 flex flex-wrap gap-2">
{#each people as person, index (person.id)}
{#if assetViewerManager.isShowingHiddenPeople || !person.isHidden}
{@const isHighlighted = people[index].faces.some((f) =>
assetViewerManager.highlightedFaces.some((b) => b.id === f.id),
)}
<a
class="group w-22 outline-none"
href={Route.viewPerson(person, { previousRoute })}
onfocus={() => assetViewerManager.setHighlightedFaces(people[index].faces)}
onblur={() => assetViewerManager.clearHighlightedFaces()}
onpointerenter={() => assetViewerManager.setHighlightedFaces(people[index].faces)}
onpointerleave={() => assetViewerManager.clearHighlightedFaces()}
>
<div class="relative">
<ImageThumbnail
curve
shadow
url={getPeopleThumbnailUrl(person)}
altText={person.name}
title={person.name}
widthStyle="90px"
heightStyle="90px"
hidden={person.isHidden}
highlighted={isHighlighted}
class="group-focus-visible:outline-2 group-focus-visible:outline-offset-2 group-focus-visible:outline-immich-primary dark:group-focus-visible:outline-immich-dark-primary"
/>
</div>
<p class="mt-1 truncate font-medium" title={person.name}>{person.name}</p>
{#if person.birthDate}
{@const personBirthDate = DateTime.fromISO(person.birthDate)}
{@const age = Math.floor(DateTime.fromISO(asset.localDateTime).diff(personBirthDate, 'years').years)}
{@const ageInMonths = Math.floor(
DateTime.fromISO(asset.localDateTime).diff(personBirthDate, 'months').months,
)}
{#if age >= 0}
<p
class="font-light"
title={personBirthDate.toLocaleString(
{
month: 'long',
day: 'numeric',
year: 'numeric',
},
{ locale: $locale },
)}
>
{#if ageInMonths <= 11}
{$t('age_months', { values: { months: ageInMonths } })}
{:else if ageInMonths > 12 && ageInMonths <= 23}
{$t('age_year_months', { values: { months: ageInMonths - 12 } })}
{:else}
{$t('age_years', { values: { years: age } })}
{/if}
</p>
{/if}
{/if}
</a>
{/if}
{/each}
</div>
</section>
{/if}
<DetailPanelPeople {asset} {isOwner} {previousRoute} />
<div class="px-4 py-4">
{#if asset.exifInfo}

View File

@@ -0,0 +1,133 @@
<script lang="ts">
import ImageThumbnail from '$lib/components/assets/thumbnail/ImageThumbnail.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { Route } from '$lib/route';
import { locale } from '$lib/stores/preferences.store';
import { getPeopleThumbnailUrl } from '$lib/utils';
import { type AssetResponseDto } from '@immich/sdk';
import { IconButton, Text } from '@immich/ui';
import { mdiEye, mdiEyeOff, mdiPencil, mdiPlus } from '@mdi/js';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
type Props = {
asset: AssetResponseDto;
isOwner: boolean;
previousRoute: string;
};
const { asset, isOwner, previousRoute }: Props = $props();
const unassignedFaces = $derived(asset.unassignedFaces || []);
const people = $derived(asset.people || []);
const visiblePeople = $derived(
people
.filter((p) => assetViewerManager.isShowingHiddenPeople || !p.isHidden)
.map((person) => {
if (!person.birthDate) {
return { formattedBirthDate: undefined, formattedAge: undefined, ...person };
}
const personBirthDate = DateTime.fromISO(person.birthDate);
const ageInYears = Math.floor(DateTime.fromISO(asset.localDateTime).diff(personBirthDate, 'years').years);
const ageInMonths = Math.floor(DateTime.fromISO(asset.localDateTime).diff(personBirthDate, 'months').months);
let formattedAge;
if (ageInYears < 0) {
return { formattedBirthDate: undefined, formattedAge: undefined, ...person };
} else if (ageInMonths < 12) {
formattedAge = $t('age_months', { values: { months: ageInMonths } });
} else if (ageInMonths > 12 && ageInMonths < 24) {
formattedAge = $t('age_year_months', { values: { months: ageInMonths - 12 } });
} else {
formattedAge = $t('age_years', { values: { years: ageInYears } });
}
const formattedBirthDate = personBirthDate.toLocaleString(
{
month: 'long',
day: 'numeric',
year: 'numeric',
},
{ locale: $locale },
);
return { formattedBirthDate, formattedAge, ...person };
}),
);
</script>
{#if !authManager.isSharedLink && isOwner}
<section class="px-4 pt-4 text-sm">
<div class="flex h-10 w-full items-center justify-between">
<Text size="small" color="muted">{$t('people')}</Text>
<div class="flex gap-2 items-center">
{#if people.some((person) => person.isHidden)}
<IconButton
aria-label={$t('show_hidden_people')}
icon={assetViewerManager.isShowingHiddenPeople ? mdiEyeOff : mdiEye}
size="medium"
shape="round"
color="secondary"
variant="ghost"
onclick={() => assetViewerManager.toggleHiddenPeople()}
/>
{/if}
<IconButton
aria-label={$t('tag_people')}
icon={mdiPlus}
size="medium"
shape="round"
color="secondary"
variant="ghost"
onclick={() => assetViewerManager.toggleFaceEditMode()}
/>
{#if people.length > 0 || unassignedFaces.length > 0}
<IconButton
aria-label={$t('edit_people')}
icon={mdiPencil}
size="medium"
shape="round"
color="secondary"
variant="ghost"
onclick={() => assetViewerManager.openEditFacesPanel()}
/>
{/if}
</div>
</div>
<div class="mt-2 grid {visiblePeople.length <= 6 ? 'grid-cols-3 gap-3' : 'grid-cols-4 gap-2'}">
{#each visiblePeople as person (person.id)}
{@const isHighlighted = person.faces.some((f) =>
assetViewerManager.highlightedFaces.some((b) => b.id === f.id),
)}
<a
class="group outline-none"
href={Route.viewPerson(person, { previousRoute })}
onfocus={() => assetViewerManager.setHighlightedFaces(person.faces)}
onblur={() => assetViewerManager.clearHighlightedFaces()}
onpointerenter={() => assetViewerManager.setHighlightedFaces(person.faces)}
onpointerleave={() => assetViewerManager.clearHighlightedFaces()}
>
<ImageThumbnail
curve
shadow
url={getPeopleThumbnailUrl(person)}
altText={person.name}
title={person.name}
widthStyle="100%"
hidden={person.isHidden}
highlighted={isHighlighted}
class="group-focus-visible:outline-2 outline-offset-2 outline-immich-primary dark:outline-immich-dark-primary"
/>
<p class="mt-1 truncate font-medium" title={person.name}>{person.name}</p>
{#if person.birthDate && person.formattedAge}
<p class="font-light {visiblePeople.length > 6 ? 'text-xs' : ''}" title={person.formattedBirthDate!}>
{person.formattedAge}
</p>
{/if}
</a>
{/each}
</div>
</section>
{/if}

View File

@@ -45,10 +45,7 @@
await deleteAssets(
force,
(assetIds) => {
timelineManager.removeAssets(assetIds);
eventManager.emit('AssetsDelete', assetIds);
},
(assetIds) => timelineManager.removeAssets(assetIds),
selectedAssets,
force ? undefined : (assets) => timelineManager.upsertAssets(assets),
);

View File

@@ -33,6 +33,8 @@ class MemoryManager {
if (authManager.authenticated) {
void this.initialize();
}
this.scheduleHourlyRefresh();
}
ready() {
@@ -132,6 +134,29 @@ class MemoryManager {
const memories = await searchMemories({ $for: asLocalTimeISO(DateTime.now()) });
this.memories = memories.filter((memory) => memory.assets.length > 0);
}
private scheduleHourlyRefresh() {
const now = DateTime.utc();
let nextEvent = now.set({ minute: 0, second: 5 });
if (nextEvent <= now) {
nextEvent = nextEvent.plus({ hours: 1 });
}
const initialDelay = nextEvent.diff(now).as('milliseconds');
setTimeout(() => {
this.#loading = this.load();
// Schedule subsequent events hourly
setInterval(
() => {
this.#loading = this.load();
},
60 * 60 * 1000,
);
}, initialDelay);
}
}
export const memoryManager = new MemoryManager();

View File

@@ -80,6 +80,8 @@ websocket
.on('on_new_release', (event) => eventManager.emit('ReleaseEvent', event))
.on('on_session_delete', () => eventManager.emit('SessionDelete'))
.on('on_user_delete', (id) => eventManager.emit('UserAdminDeleted', { id }))
.on('on_asset_delete', (asset) => eventManager.emit('AssetsDelete', [asset]))
.on('on_asset_trash', (assets) => eventManager.emit('AssetsDelete', assets))
.on('on_asset_update', (asset) => eventManager.emit('AssetUpdate', asset))
.on('on_person_thumbnail', (id) => eventManager.emit('PersonThumbnailReady', { id }))
.on('on_notification', () => notificationManager.refresh())

View File

@@ -1,11 +1,12 @@
<script lang="ts">
import type { Action } from '$lib/components/asset-viewer/actions/action';
import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import LargeAssetData from './LargeAssetData.svelte';
import Portal from '$lib/elements/Portal.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { handlePromiseError } from '$lib/utils';
import { getNextAsset, getPreviousAsset } from '$lib/utils/asset-utils';
import { getNextAsset, getPreviousAsset, navigateToAsset } from '$lib/utils/asset-utils';
import { navigate } from '$lib/utils/navigation';
import type { AssetResponseDto } from '@immich/sdk';
import { t } from 'svelte-i18n';
@@ -17,7 +18,7 @@
let { data }: Props = $props();
let assets = $derived(data.assets);
let assets = $state(data.assets);
let asset = $derived(data.asset);
$effect(() => {
@@ -36,13 +37,19 @@
return asset;
};
const onAction = (payload: Action) => {
const preAction = async (payload: Action) => {
if (payload.type == 'trash') {
assets = assets.filter((a) => a.id != payload.asset.id);
assetViewerManager.showAssetViewer(false);
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
(await navigateToAsset(assetCursor?.nextAsset)) ||
(await navigateToAsset(assetCursor?.previousAsset)) ||
assetViewerManager.showAssetViewer(false);
}
};
const onAssetsDelete = (assetIds: string[]) => {
assets = assets.filter(({ id }) => !assetIds.includes(id));
};
const onViewAsset = async (asset: AssetResponseDto) => {
await navigate({ targetRoute: 'current', assetId: asset.id });
};
@@ -54,9 +61,11 @@
});
</script>
<OnEvents {onAssetsDelete} />
<UserPageLayout title={data.meta.title} scrollbar={true}>
<div class="grid gap-2 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6">
{#if assets && data.assets.length > 0}
{#if assets && assets.length > 0}
{#each assets as asset (asset.id)}
<LargeAssetData {asset} {onViewAsset} />
{/each}
@@ -75,7 +84,7 @@
cursor={assetCursor}
showNavigation={assets.length > 1}
{onRandom}
{onAction}
{preAction}
onClose={() => {
assetViewerManager.showAssetViewer(false);
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));