Compare commits

...

8 Commits

Author SHA1 Message Date
Mees Frensel
3644f67f59 use single query param 2026-03-05 11:02:02 +01:00
Mees Frensel
372aad07d5 fix(web): gracefully handle map errors when WebGL is disabled 2026-02-26 17:53:38 +01:00
Mees Frensel
e454c3566b refactor: star rating (#26357)
* refactor: star rating

* transform rating 0 to null in controller dto

* migrate rating 0 to null

* deprecate rating -1

* rating type annotation

* update Rating type
2026-02-26 14:54:20 +01:00
Luis Nachtigall
4c79c3c902 feat(mobile): Prevent premature image cache eviction when higher image loading is enabled (#26208)
* feat(mobile): enhance image loading with error handling and eviction options

* pull request suggestions: remove log and remove useless local implementation
2026-02-25 17:31:37 -05:00
Alex
3bed1b6131 fix: consider DAR when extracting video dimension (#25293)
* feat: consider DAR when extracting video dimension

* move to probe

* comment

---------

Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
2026-02-25 21:58:53 +00:00
Brandon Wees
3c9fb651d0 feat(server): SyncAssetEditV1 (#26446)
* feat: SyncAssetEditV1

* fix: audit table import

* fix: sql tools table fetch

* fix: medium tests (wip)

* fix: circ dependency

* chore: finalize tests

* chore: codegen/lint

* fix: code review
2026-02-25 18:12:41 +00:00
Mees Frensel
55e625a2ac fix(web): error page i18n (#26517) 2026-02-25 18:35:25 +01:00
Daniel Dietzler
ca6c486a80 refactor: person stubs (#26512) 2026-02-25 08:56:00 -05:00
63 changed files with 1713 additions and 714 deletions

View File

@@ -1050,6 +1050,7 @@
"cant_get_number_of_comments": "Can't get number of comments",
"cant_search_people": "Can't search people",
"cant_search_places": "Can't search places",
"enable_webgl_for_map": "Enable WebGL to load the map.{isAdmin, select, true { To hide this warning, disable the map feature.} other {}}",
"error_adding_assets_to_album": "Error adding assets to album",
"error_adding_users_to_album": "Error adding users to album",
"error_deleting_shared_user": "Error deleting shared user",
@@ -1074,6 +1075,7 @@
"failed_to_update_notification_status": "Failed to update notification status",
"incorrect_email_or_password": "Incorrect email or password",
"library_folder_already_exists": "This import path already exists.",
"page_not_found": "Page not found :/",
"paths_validation_failed": "{paths, plural, one {# path} other {# paths}} failed validation",
"profile_picture_transparent_pixels": "Profile pictures cannot have transparent pixels. Please zoom in and/or move the image.",
"quota_higher_than_disk_size": "You set a quota higher than the disk size",
@@ -1244,6 +1246,7 @@
"go_back": "Go back",
"go_to_folder": "Go to folder",
"go_to_search": "Go to search",
"go_to_settings": "Go to settings",
"gps": "GPS",
"gps_missing": "No GPS",
"grant_permission": "Grant permission",
@@ -1809,9 +1812,8 @@
"rate_asset": "Rate Asset",
"rating": "Star rating",
"rating_clear": "Clear rating",
"rating_count": "{count, plural, one {# star} other {# stars}}",
"rating_count": "{count, plural, =0 {Unrated} one {# star} other {# stars}}",
"rating_description": "Display the EXIF rating in the info panel",
"rating_set": "Rating set to {rating, plural, one {# star} other {# stars}}",
"reaction_options": "Reaction options",
"read_changelog": "Read Changelog",
"readonly_mode_disabled": "Read-only mode disabled",

View File

@@ -48,7 +48,7 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
return null;
}
Stream<ImageInfo> loadRequest(ImageRequest request, ImageDecoderCallback decode) async* {
Stream<ImageInfo> loadRequest(ImageRequest request, ImageDecoderCallback decode, {bool evictOnError = true}) async* {
if (isCancelled) {
this.request = null;
PaintingBinding.instance.imageCache.evict(this);
@@ -57,14 +57,19 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
try {
final image = await request.load(decode);
if (image == null || isCancelled) {
if ((image == null && evictOnError) || isCancelled) {
PaintingBinding.instance.imageCache.evict(this);
return;
} else if (image == null) {
return;
}
yield image;
} catch (e) {
PaintingBinding.instance.imageCache.evict(this);
rethrow;
} catch (e, stack) {
if (evictOnError) {
PaintingBinding.instance.imageCache.evict(this);
rethrow;
}
_log.warning('Non-fatal image load error', e, stack);
} finally {
this.request = null;
}

View File

@@ -94,7 +94,6 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio),
assetType: key.assetType,
);
yield* loadRequest(request, decode);
if (!Store.get(StoreKey.loadOriginal, false)) {

View File

@@ -93,9 +93,10 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
uri: getThumbnailUrlForRemoteId(key.assetId, type: AssetMediaSize.preview, thumbhash: key.thumbhash),
headers: headers,
);
yield* loadRequest(previewRequest, decode);
final loadOriginal = assetType == AssetType.image && AppSetting.get(Setting.loadOriginal);
yield* loadRequest(previewRequest, decode, evictOnError: !loadOriginal);
if (assetType != AssetType.image || !AppSetting.get(Setting.loadOriginal)) {
if (!loadOriginal) {
return;
}

View File

@@ -576,6 +576,8 @@ Class | Method | HTTP request | Description
- [SyncAlbumUserV1](doc//SyncAlbumUserV1.md)
- [SyncAlbumV1](doc//SyncAlbumV1.md)
- [SyncAssetDeleteV1](doc//SyncAssetDeleteV1.md)
- [SyncAssetEditDeleteV1](doc//SyncAssetEditDeleteV1.md)
- [SyncAssetEditV1](doc//SyncAssetEditV1.md)
- [SyncAssetExifV1](doc//SyncAssetExifV1.md)
- [SyncAssetFaceDeleteV1](doc//SyncAssetFaceDeleteV1.md)
- [SyncAssetFaceV1](doc//SyncAssetFaceV1.md)

View File

@@ -315,6 +315,8 @@ part 'model/sync_album_user_delete_v1.dart';
part 'model/sync_album_user_v1.dart';
part 'model/sync_album_v1.dart';
part 'model/sync_asset_delete_v1.dart';
part 'model/sync_asset_edit_delete_v1.dart';
part 'model/sync_asset_edit_v1.dart';
part 'model/sync_asset_exif_v1.dart';
part 'model/sync_asset_face_delete_v1.dart';
part 'model/sync_asset_face_v1.dart';

View File

@@ -410,7 +410,7 @@ class SearchApi {
/// Filter by person IDs
///
/// * [num] rating:
/// Filter by rating
/// Filter by rating [1-5], or null for unrated
///
/// * [num] size:
/// Number of results to return
@@ -633,7 +633,7 @@ class SearchApi {
/// Filter by person IDs
///
/// * [num] rating:
/// Filter by rating
/// Filter by rating [1-5], or null for unrated
///
/// * [num] size:
/// Number of results to return

View File

@@ -676,6 +676,10 @@ class ApiClient {
return SyncAlbumV1.fromJson(value);
case 'SyncAssetDeleteV1':
return SyncAssetDeleteV1.fromJson(value);
case 'SyncAssetEditDeleteV1':
return SyncAssetEditDeleteV1.fromJson(value);
case 'SyncAssetEditV1':
return SyncAssetEditV1.fromJson(value);
case 'SyncAssetExifV1':
return SyncAssetExifV1.fromJson(value);
case 'SyncAssetFaceDeleteV1':

View File

@@ -86,16 +86,10 @@ class AssetBulkUpdateDto {
///
num? longitude;
/// Rating
/// Rating in range [1-5], or null for unrated
///
/// Minimum value: -1
/// Maximum value: 5
///
/// 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? rating;
/// Time zone (IANA timezone)
@@ -223,7 +217,9 @@ class AssetBulkUpdateDto {
isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
latitude: num.parse('${json[r'latitude']}'),
longitude: num.parse('${json[r'longitude']}'),
rating: num.parse('${json[r'rating']}'),
rating: json[r'rating'] == null
? null
: num.parse('${json[r'rating']}'),
timeZone: mapValueOfType<String>(json, r'timeZone'),
visibility: AssetVisibility.fromJson(json[r'visibility']),
);

View File

@@ -256,16 +256,10 @@ class MetadataSearchDto {
///
String? previewPath;
/// Filter by rating
/// Filter by rating [1-5], or null for unrated
///
/// Minimum value: -1
/// Maximum value: 5
///
/// 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? rating;
/// Number of results to return
@@ -754,7 +748,9 @@ class MetadataSearchDto {
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
: const [],
previewPath: mapValueOfType<String>(json, r'previewPath'),
rating: num.parse('${json[r'rating']}'),
rating: json[r'rating'] == null
? null
: num.parse('${json[r'rating']}'),
size: num.parse('${json[r'size']}'),
state: mapValueOfType<String>(json, r'state'),
tagIds: json[r'tagIds'] is Iterable

View File

@@ -159,16 +159,10 @@ class RandomSearchDto {
/// Filter by person IDs
List<String> personIds;
/// Filter by rating
/// Filter by rating [1-5], or null for unrated
///
/// Minimum value: -1
/// Maximum value: 5
///
/// 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? rating;
/// Number of results to return
@@ -565,7 +559,9 @@ class RandomSearchDto {
personIds: json[r'personIds'] is Iterable
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
: const [],
rating: num.parse('${json[r'rating']}'),
rating: json[r'rating'] == null
? null
: num.parse('${json[r'rating']}'),
size: num.parse('${json[r'size']}'),
state: mapValueOfType<String>(json, r'state'),
tagIds: json[r'tagIds'] is Iterable

View File

@@ -199,16 +199,10 @@ class SmartSearchDto {
///
String? queryAssetId;
/// Filter by rating
/// Filter by rating [1-5], or null for unrated
///
/// Minimum value: -1
/// Maximum value: 5
///
/// 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? rating;
/// Number of results to return
@@ -605,7 +599,9 @@ class SmartSearchDto {
: const [],
query: mapValueOfType<String>(json, r'query'),
queryAssetId: mapValueOfType<String>(json, r'queryAssetId'),
rating: num.parse('${json[r'rating']}'),
rating: json[r'rating'] == null
? null
: num.parse('${json[r'rating']}'),
size: num.parse('${json[r'size']}'),
state: mapValueOfType<String>(json, r'state'),
tagIds: json[r'tagIds'] is Iterable

View File

@@ -164,16 +164,10 @@ class StatisticsSearchDto {
/// Filter by person IDs
List<String> personIds;
/// Filter by rating
/// Filter by rating [1-5], or null for unrated
///
/// Minimum value: -1
/// Maximum value: 5
///
/// 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? rating;
/// Filter by state/province name
@@ -495,7 +489,9 @@ class StatisticsSearchDto {
personIds: json[r'personIds'] is Iterable
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
: const [],
rating: num.parse('${json[r'rating']}'),
rating: json[r'rating'] == null
? null
: num.parse('${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

@@ -0,0 +1,99 @@
//
// 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 SyncAssetEditDeleteV1 {
/// Returns a new [SyncAssetEditDeleteV1] instance.
SyncAssetEditDeleteV1({
required this.editId,
});
String editId;
@override
bool operator ==(Object other) => identical(this, other) || other is SyncAssetEditDeleteV1 &&
other.editId == editId;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(editId.hashCode);
@override
String toString() => 'SyncAssetEditDeleteV1[editId=$editId]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'editId'] = this.editId;
return json;
}
/// Returns a new [SyncAssetEditDeleteV1] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static SyncAssetEditDeleteV1? fromJson(dynamic value) {
upgradeDto(value, "SyncAssetEditDeleteV1");
if (value is Map) {
final json = value.cast<String, dynamic>();
return SyncAssetEditDeleteV1(
editId: mapValueOfType<String>(json, r'editId')!,
);
}
return null;
}
static List<SyncAssetEditDeleteV1> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SyncAssetEditDeleteV1>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = SyncAssetEditDeleteV1.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, SyncAssetEditDeleteV1> mapFromJson(dynamic json) {
final map = <String, SyncAssetEditDeleteV1>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = SyncAssetEditDeleteV1.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of SyncAssetEditDeleteV1-objects as value to a dart map
static Map<String, List<SyncAssetEditDeleteV1>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SyncAssetEditDeleteV1>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = SyncAssetEditDeleteV1.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'editId',
};
}

View File

@@ -0,0 +1,131 @@
//
// 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 SyncAssetEditV1 {
/// Returns a new [SyncAssetEditV1] instance.
SyncAssetEditV1({
required this.action,
required this.assetId,
required this.id,
required this.parameters,
required this.sequence,
});
AssetEditAction action;
String assetId;
String id;
Object parameters;
int sequence;
@override
bool operator ==(Object other) => identical(this, other) || other is SyncAssetEditV1 &&
other.action == action &&
other.assetId == assetId &&
other.id == id &&
other.parameters == parameters &&
other.sequence == sequence;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(action.hashCode) +
(assetId.hashCode) +
(id.hashCode) +
(parameters.hashCode) +
(sequence.hashCode);
@override
String toString() => 'SyncAssetEditV1[action=$action, assetId=$assetId, id=$id, parameters=$parameters, sequence=$sequence]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'action'] = this.action;
json[r'assetId'] = this.assetId;
json[r'id'] = this.id;
json[r'parameters'] = this.parameters;
json[r'sequence'] = this.sequence;
return json;
}
/// Returns a new [SyncAssetEditV1] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static SyncAssetEditV1? fromJson(dynamic value) {
upgradeDto(value, "SyncAssetEditV1");
if (value is Map) {
final json = value.cast<String, dynamic>();
return SyncAssetEditV1(
action: AssetEditAction.fromJson(json[r'action'])!,
assetId: mapValueOfType<String>(json, r'assetId')!,
id: mapValueOfType<String>(json, r'id')!,
parameters: mapValueOfType<Object>(json, r'parameters')!,
sequence: mapValueOfType<int>(json, r'sequence')!,
);
}
return null;
}
static List<SyncAssetEditV1> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SyncAssetEditV1>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = SyncAssetEditV1.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, SyncAssetEditV1> mapFromJson(dynamic json) {
final map = <String, SyncAssetEditV1>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = SyncAssetEditV1.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of SyncAssetEditV1-objects as value to a dart map
static Map<String, List<SyncAssetEditV1>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SyncAssetEditV1>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = SyncAssetEditV1.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'action',
'assetId',
'id',
'parameters',
'sequence',
};
}

View File

@@ -29,6 +29,8 @@ class SyncEntityType {
static const assetV1 = SyncEntityType._(r'AssetV1');
static const assetDeleteV1 = SyncEntityType._(r'AssetDeleteV1');
static const assetExifV1 = SyncEntityType._(r'AssetExifV1');
static const assetEditV1 = SyncEntityType._(r'AssetEditV1');
static const assetEditDeleteV1 = SyncEntityType._(r'AssetEditDeleteV1');
static const assetMetadataV1 = SyncEntityType._(r'AssetMetadataV1');
static const assetMetadataDeleteV1 = SyncEntityType._(r'AssetMetadataDeleteV1');
static const partnerV1 = SyncEntityType._(r'PartnerV1');
@@ -80,6 +82,8 @@ class SyncEntityType {
assetV1,
assetDeleteV1,
assetExifV1,
assetEditV1,
assetEditDeleteV1,
assetMetadataV1,
assetMetadataDeleteV1,
partnerV1,
@@ -166,6 +170,8 @@ class SyncEntityTypeTypeTransformer {
case r'AssetV1': return SyncEntityType.assetV1;
case r'AssetDeleteV1': return SyncEntityType.assetDeleteV1;
case r'AssetExifV1': return SyncEntityType.assetExifV1;
case r'AssetEditV1': return SyncEntityType.assetEditV1;
case r'AssetEditDeleteV1': return SyncEntityType.assetEditDeleteV1;
case r'AssetMetadataV1': return SyncEntityType.assetMetadataV1;
case r'AssetMetadataDeleteV1': return SyncEntityType.assetMetadataDeleteV1;
case r'PartnerV1': return SyncEntityType.partnerV1;

View File

@@ -30,6 +30,7 @@ class SyncRequestType {
static const albumAssetExifsV1 = SyncRequestType._(r'AlbumAssetExifsV1');
static const assetsV1 = SyncRequestType._(r'AssetsV1');
static const assetExifsV1 = SyncRequestType._(r'AssetExifsV1');
static const assetEditsV1 = SyncRequestType._(r'AssetEditsV1');
static const assetMetadataV1 = SyncRequestType._(r'AssetMetadataV1');
static const authUsersV1 = SyncRequestType._(r'AuthUsersV1');
static const memoriesV1 = SyncRequestType._(r'MemoriesV1');
@@ -54,6 +55,7 @@ class SyncRequestType {
albumAssetExifsV1,
assetsV1,
assetExifsV1,
assetEditsV1,
assetMetadataV1,
authUsersV1,
memoriesV1,
@@ -113,6 +115,7 @@ class SyncRequestTypeTypeTransformer {
case r'AlbumAssetExifsV1': return SyncRequestType.albumAssetExifsV1;
case r'AssetsV1': return SyncRequestType.assetsV1;
case r'AssetExifsV1': return SyncRequestType.assetExifsV1;
case r'AssetEditsV1': return SyncRequestType.assetEditsV1;
case r'AssetMetadataV1': return SyncRequestType.assetMetadataV1;
case r'AuthUsersV1': return SyncRequestType.authUsersV1;
case r'MemoriesV1': return SyncRequestType.memoriesV1;

View File

@@ -71,16 +71,10 @@ class UpdateAssetDto {
///
num? longitude;
/// Rating
/// Rating in range [1-5], or null for unrated
///
/// Minimum value: -1
/// Maximum value: 5
///
/// 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? rating;
/// Asset visibility
@@ -178,7 +172,9 @@ class UpdateAssetDto {
latitude: num.parse('${json[r'latitude']}'),
livePhotoVideoId: mapValueOfType<String>(json, r'livePhotoVideoId'),
longitude: num.parse('${json[r'longitude']}'),
rating: num.parse('${json[r'rating']}'),
rating: json[r'rating'] == null
? null
: num.parse('${json[r'rating']}'),
visibility: AssetVisibility.fromJson(json[r'visibility']),
);
}

View File

@@ -9407,10 +9407,27 @@
"name": "rating",
"required": false,
"in": "query",
"description": "Filter by rating",
"description": "Filter by rating [1-5], or null for unrated",
"x-immich-history": [
{
"version": "v1",
"state": "Added"
},
{
"version": "v2",
"state": "Stable"
},
{
"version": "v2.6.0",
"state": "Updated",
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
}
],
"x-immich-state": "Stable",
"schema": {
"minimum": -1,
"maximum": 5,
"nullable": true,
"type": "number"
}
},
@@ -15872,10 +15889,27 @@
"type": "number"
},
"rating": {
"description": "Rating",
"description": "Rating in range [1-5], or null for unrated",
"maximum": 5,
"minimum": -1,
"type": "number"
"nullable": true,
"type": "number",
"x-immich-history": [
{
"version": "v1",
"state": "Added"
},
{
"version": "v2",
"state": "Stable"
},
{
"version": "v2.6.0",
"state": "Updated",
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
}
],
"x-immich-state": "Stable"
},
"timeZone": {
"description": "Time zone (IANA timezone)",
@@ -18988,10 +19022,27 @@
"type": "string"
},
"rating": {
"description": "Filter by rating",
"description": "Filter by rating [1-5], or null for unrated",
"maximum": 5,
"minimum": -1,
"type": "number"
"nullable": true,
"type": "number",
"x-immich-history": [
{
"version": "v1",
"state": "Added"
},
{
"version": "v2",
"state": "Stable"
},
{
"version": "v2.6.0",
"state": "Updated",
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
}
],
"x-immich-state": "Stable"
},
"size": {
"description": "Number of results to return",
@@ -20714,10 +20765,27 @@
"type": "array"
},
"rating": {
"description": "Filter by rating",
"description": "Filter by rating [1-5], or null for unrated",
"maximum": 5,
"minimum": -1,
"type": "number"
"nullable": true,
"type": "number",
"x-immich-history": [
{
"version": "v1",
"state": "Added"
},
{
"version": "v2",
"state": "Stable"
},
{
"version": "v2.6.0",
"state": "Updated",
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
}
],
"x-immich-state": "Stable"
},
"size": {
"description": "Number of results to return",
@@ -22088,10 +22156,27 @@
"type": "string"
},
"rating": {
"description": "Filter by rating",
"description": "Filter by rating [1-5], or null for unrated",
"maximum": 5,
"minimum": -1,
"type": "number"
"nullable": true,
"type": "number",
"x-immich-history": [
{
"version": "v1",
"state": "Added"
},
{
"version": "v2",
"state": "Stable"
},
{
"version": "v2.6.0",
"state": "Updated",
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
}
],
"x-immich-state": "Stable"
},
"size": {
"description": "Number of results to return",
@@ -22322,10 +22407,27 @@
"type": "array"
},
"rating": {
"description": "Filter by rating",
"description": "Filter by rating [1-5], or null for unrated",
"maximum": 5,
"minimum": -1,
"type": "number"
"nullable": true,
"type": "number",
"x-immich-history": [
{
"version": "v1",
"state": "Added"
},
{
"version": "v2",
"state": "Stable"
},
{
"version": "v2.6.0",
"state": "Updated",
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
}
],
"x-immich-state": "Stable"
},
"state": {
"description": "Filter by state/province name",
@@ -22614,6 +22716,48 @@
],
"type": "object"
},
"SyncAssetEditDeleteV1": {
"properties": {
"editId": {
"type": "string"
}
},
"required": [
"editId"
],
"type": "object"
},
"SyncAssetEditV1": {
"properties": {
"action": {
"allOf": [
{
"$ref": "#/components/schemas/AssetEditAction"
}
]
},
"assetId": {
"type": "string"
},
"id": {
"type": "string"
},
"parameters": {
"type": "object"
},
"sequence": {
"type": "integer"
}
},
"required": [
"action",
"assetId",
"id",
"parameters",
"sequence"
],
"type": "object"
},
"SyncAssetExifV1": {
"properties": {
"assetId": {
@@ -23163,6 +23307,8 @@
"AssetV1",
"AssetDeleteV1",
"AssetExifV1",
"AssetEditV1",
"AssetEditDeleteV1",
"AssetMetadataV1",
"AssetMetadataDeleteV1",
"PartnerV1",
@@ -23460,6 +23606,7 @@
"AlbumAssetExifsV1",
"AssetsV1",
"AssetExifsV1",
"AssetEditsV1",
"AssetMetadataV1",
"AuthUsersV1",
"MemoriesV1",
@@ -25164,10 +25311,27 @@
"type": "number"
},
"rating": {
"description": "Rating",
"description": "Rating in range [1-5], or null for unrated",
"maximum": 5,
"minimum": -1,
"type": "number"
"nullable": true,
"type": "number",
"x-immich-history": [
{
"version": "v1",
"state": "Added"
},
{
"version": "v2",
"state": "Stable"
},
{
"version": "v2.6.0",
"state": "Updated",
"description": "Using -1 as a rating is deprecated and will be removed in the next major version."
}
],
"x-immich-state": "Stable"
},
"visibility": {
"allOf": [

View File

@@ -834,8 +834,8 @@ export type AssetBulkUpdateDto = {
latitude?: number;
/** Longitude coordinate */
longitude?: number;
/** Rating */
rating?: number;
/** Rating in range [1-5], or null for unrated */
rating?: number | null;
/** Time zone (IANA timezone) */
timeZone?: string;
/** Asset visibility */
@@ -944,8 +944,8 @@ export type UpdateAssetDto = {
livePhotoVideoId?: string | null;
/** Longitude coordinate */
longitude?: number;
/** Rating */
rating?: number;
/** Rating in range [1-5], or null for unrated */
rating?: number | null;
/** Asset visibility */
visibility?: AssetVisibility;
};
@@ -1711,8 +1711,8 @@ export type MetadataSearchDto = {
personIds?: string[];
/** Filter by preview file path */
previewPath?: string;
/** Filter by rating */
rating?: number;
/** Filter by rating [1-5], or null for unrated */
rating?: number | null;
/** Number of results to return */
size?: number;
/** Filter by state/province name */
@@ -1827,8 +1827,8 @@ export type RandomSearchDto = {
ocr?: string;
/** Filter by person IDs */
personIds?: string[];
/** Filter by rating */
rating?: number;
/** Filter by rating [1-5], or null for unrated */
rating?: number | null;
/** Number of results to return */
size?: number;
/** Filter by state/province name */
@@ -1903,8 +1903,8 @@ export type SmartSearchDto = {
query?: string;
/** Asset ID to use as search reference */
queryAssetId?: string;
/** Filter by rating */
rating?: number;
/** Filter by rating [1-5], or null for unrated */
rating?: number | null;
/** Number of results to return */
size?: number;
/** Filter by state/province name */
@@ -1969,8 +1969,8 @@ export type StatisticsSearchDto = {
ocr?: string;
/** Filter by person IDs */
personIds?: string[];
/** Filter by rating */
rating?: number;
/** Filter by rating [1-5], or null for unrated */
rating?: number | null;
/** Filter by state/province name */
state?: string | null;
/** Filter by tag IDs */
@@ -2967,6 +2967,16 @@ export type SyncAssetDeleteV1 = {
/** Asset ID */
assetId: string;
};
export type SyncAssetEditDeleteV1 = {
editId: string;
};
export type SyncAssetEditV1 = {
action: AssetEditAction;
assetId: string;
id: string;
parameters: object;
sequence: number;
};
export type SyncAssetExifV1 = {
/** Asset ID */
assetId: string;
@@ -5444,7 +5454,7 @@ export function searchLargeAssets({ albumIds, city, country, createdAfter, creat
model?: string | null;
ocr?: string;
personIds?: string[];
rating?: number;
rating?: number | null;
size?: number;
state?: string | null;
tagIds?: string[] | null;
@@ -7230,6 +7240,8 @@ export enum SyncEntityType {
AssetV1 = "AssetV1",
AssetDeleteV1 = "AssetDeleteV1",
AssetExifV1 = "AssetExifV1",
AssetEditV1 = "AssetEditV1",
AssetEditDeleteV1 = "AssetEditDeleteV1",
AssetMetadataV1 = "AssetMetadataV1",
AssetMetadataDeleteV1 = "AssetMetadataDeleteV1",
PartnerV1 = "PartnerV1",
@@ -7281,6 +7293,7 @@ export enum SyncRequestType {
AlbumAssetExifsV1 = "AlbumAssetExifsV1",
AssetsV1 = "AssetsV1",
AssetExifsV1 = "AssetExifsV1",
AssetEditsV1 = "AssetEditsV1",
AssetMetadataV1 = "AssetMetadataV1",
AuthUsersV1 = "AuthUsersV1",
MemoriesV1 = "MemoriesV1",

View File

@@ -207,12 +207,28 @@ describe(AssetController.name, () => {
});
it('should reject invalid rating', async () => {
for (const test of [{ rating: 7 }, { rating: 3.5 }, { rating: null }]) {
for (const test of [{ rating: 7 }, { rating: 3.5 }, { rating: -2 }]) {
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}`).send(test);
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest());
}
});
it('should convert rating 0 to null', async () => {
const assetId = factory.uuid();
const { status } = await request(ctx.getHttpServer()).put(`/assets/${assetId}`).send({ rating: 0 });
expect(service.update).toHaveBeenCalledWith(undefined, assetId, { rating: null });
expect(status).toBe(200);
});
it('should leave correct ratings as-is', async () => {
const assetId = factory.uuid();
for (const test of [{ rating: -1 }, { rating: 1 }, { rating: 5 }]) {
const { status } = await request(ctx.getHttpServer()).put(`/assets/${assetId}`).send(test);
expect(service.update).toHaveBeenCalledWith(undefined, assetId, test);
expect(status).toBe(200);
}
});
});
describe('GET /assets/statistics', () => {

View File

@@ -437,6 +437,13 @@ export const columns = {
'asset_exif.rating',
'asset_exif.fps',
],
syncAssetEdit: [
'asset_edit.id',
'asset_edit.assetId',
'asset_edit.sequence',
'asset_edit.action',
'asset_edit.parameters',
],
exif: [
'asset_exif.assetId',
'asset_exif.autoStackId',

View File

@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { Transform, Type } from 'class-transformer';
import {
IsArray,
IsDateString,
@@ -16,6 +16,7 @@ import {
ValidateIf,
ValidateNested,
} from 'class-validator';
import { HistoryBuilder, Property } from 'src/decorators';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AssetType, AssetVisibility } from 'src/enum';
import { AssetStats } from 'src/repositories/asset.repository';
@@ -56,12 +57,19 @@ export class UpdateAssetBase {
@IsNotEmpty()
longitude?: number;
@ApiProperty({ description: 'Rating' })
@Optional()
@Property({
description: 'Rating in range [1-5], or null for unrated',
history: new HistoryBuilder()
.added('v1')
.stable('v2')
.updated('v2.6.0', 'Using -1 as a rating is deprecated and will be removed in the next major version.'),
})
@Optional({ nullable: true })
@IsInt()
@Max(5)
@Min(-1)
rating?: number;
@Transform(({ value }) => (value === 0 ? null : value))
rating?: number | null;
@ApiProperty({ description: 'Asset description' })
@Optional()

View File

@@ -1,7 +1,6 @@
import { ApiProperty, getSchemaPath } from '@nestjs/swagger';
import { ApiExtraModels, ApiProperty, getSchemaPath } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { ArrayMinSize, IsEnum, IsInt, Min, ValidateNested } from 'class-validator';
import { ExtraModel } from 'src/dtos/sync.dto';
import { IsAxisAlignedRotation, IsUniqueEditActions, ValidateEnum, ValidateUUID } from 'src/validation';
export enum AssetEditAction {
@@ -15,7 +14,6 @@ export enum MirrorAxis {
Vertical = 'vertical',
}
@ExtraModel()
export class CropParameters {
@IsInt()
@Min(0)
@@ -38,14 +36,12 @@ export class CropParameters {
height!: number;
}
@ExtraModel()
export class RotateParameters {
@IsAxisAlignedRotation()
@ApiProperty({ description: 'Rotation angle in degrees' })
angle!: number;
}
@ExtraModel()
export class MirrorParameters {
@IsEnum(MirrorAxis)
@ApiProperty({ enum: MirrorAxis, enumName: 'MirrorAxis', description: 'Axis to mirror along' })
@@ -67,6 +63,7 @@ export type AssetEditActionItem =
parameters: MirrorParameters;
};
@ApiExtraModels(CropParameters, RotateParameters, MirrorParameters)
export class AssetEditActionItemDto {
@ValidateEnum({ name: 'AssetEditAction', enum: AssetEditAction, description: 'Type of edit action to perform' })
action!: AssetEditAction;

View File

@@ -2,7 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator';
import { Place } from 'src/database';
import { HistoryBuilder } from 'src/decorators';
import { HistoryBuilder, Property } from 'src/decorators';
import { AlbumResponseDto } from 'src/dtos/album.dto';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AssetOrder, AssetType, AssetVisibility } from 'src/enum';
@@ -103,12 +103,21 @@ class BaseSearchDto {
@ValidateUUID({ each: true, optional: true, description: 'Filter by album IDs' })
albumIds?: string[];
@ApiPropertyOptional({ type: 'number', description: 'Filter by rating', minimum: -1, maximum: 5 })
@Optional()
@Property({
type: 'number',
description: 'Filter by rating [1-5], or null for unrated',
minimum: -1,
maximum: 5,
history: new HistoryBuilder()
.added('v1')
.stable('v2')
.updated('v2.6.0', 'Using -1 as a rating is deprecated and will be removed in the next major version.'),
})
@Optional({ nullable: true })
@IsInt()
@Max(5)
@Min(-1)
rating?: number;
rating?: number | null;
@ApiPropertyOptional({ description: 'Filter by OCR text content' })
@IsString()

View File

@@ -2,6 +2,7 @@
import { ApiProperty } from '@nestjs/swagger';
import { ArrayMaxSize, IsInt, IsPositive, IsString } from 'class-validator';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AssetEditAction } from 'src/dtos/editing.dto';
import {
AlbumUserRole,
AssetOrder,
@@ -218,6 +219,24 @@ export class SyncAssetExifV1 {
fps!: number | null;
}
@ExtraModel()
export class SyncAssetEditV1 {
id!: string;
assetId!: string;
@ValidateEnum({ enum: AssetEditAction, name: 'AssetEditAction' })
action!: AssetEditAction;
parameters!: object;
@ApiProperty({ type: 'integer' })
sequence!: number;
}
@ExtraModel()
export class SyncAssetEditDeleteV1 {
editId!: string;
}
@ExtraModel()
export class SyncAssetMetadataV1 {
@ApiProperty({ description: 'Asset ID' })
@@ -480,6 +499,8 @@ export type SyncItem = {
[SyncEntityType.AssetMetadataV1]: SyncAssetMetadataV1;
[SyncEntityType.AssetMetadataDeleteV1]: SyncAssetMetadataDeleteV1;
[SyncEntityType.AssetExifV1]: SyncAssetExifV1;
[SyncEntityType.AssetEditV1]: SyncAssetEditV1;
[SyncEntityType.AssetEditDeleteV1]: SyncAssetEditDeleteV1;
[SyncEntityType.PartnerAssetV1]: SyncAssetV1;
[SyncEntityType.PartnerAssetBackfillV1]: SyncAssetV1;
[SyncEntityType.PartnerAssetDeleteV1]: SyncAssetDeleteV1;

View File

@@ -720,6 +720,7 @@ export enum SyncRequestType {
AlbumAssetExifsV1 = 'AlbumAssetExifsV1',
AssetsV1 = 'AssetsV1',
AssetExifsV1 = 'AssetExifsV1',
AssetEditsV1 = 'AssetEditsV1',
AssetMetadataV1 = 'AssetMetadataV1',
AuthUsersV1 = 'AuthUsersV1',
MemoriesV1 = 'MemoriesV1',
@@ -745,6 +746,8 @@ export enum SyncEntityType {
AssetV1 = 'AssetV1',
AssetDeleteV1 = 'AssetDeleteV1',
AssetExifV1 = 'AssetExifV1',
AssetEditV1 = 'AssetEditV1',
AssetEditDeleteV1 = 'AssetEditDeleteV1',
AssetMetadataV1 = 'AssetMetadataV1',
AssetMetadataDeleteV1 = 'AssetMetadataDeleteV1',

View File

@@ -18,3 +18,17 @@ where
"assetId" = $1
order by
"sequence" asc
-- AssetEditRepository.getWithSyncInfo
select
"asset_edit"."id",
"asset_edit"."assetId",
"asset_edit"."sequence",
"asset_edit"."action",
"asset_edit"."parameters"
from
"asset_edit"
where
"assetId" = $1
order by
"sequence" asc

View File

@@ -514,6 +514,38 @@ where
order by
"asset_exif"."updateId" asc
-- SyncRepository.assetEdit.getDeletes
select
"asset_edit_audit"."id",
"editId"
from
"asset_edit_audit" as "asset_edit_audit"
inner join "asset" on "asset"."id" = "asset_edit_audit"."assetId"
where
"asset_edit_audit"."id" < $1
and "asset_edit_audit"."id" > $2
and "asset"."ownerId" = $3
order by
"asset_edit_audit"."id" asc
-- SyncRepository.assetEdit.getUpserts
select
"asset_edit"."id",
"asset_edit"."assetId",
"asset_edit"."sequence",
"asset_edit"."action",
"asset_edit"."parameters",
"asset_edit"."updateId"
from
"asset_edit" as "asset_edit"
inner join "asset" on "asset"."id" = "asset_edit"."assetId"
where
"asset_edit"."updateId" < $1
and "asset_edit"."updateId" > $2
and "asset"."ownerId" = $3
order by
"asset_edit"."updateId" asc
-- SyncRepository.assetFace.getDeletes
select
"asset_face_audit"."id",

View File

@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { Kysely } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database';
import { DummyValue, GenerateSql } from 'src/decorators';
import { AssetEditActionItem, AssetEditActionItemResponseDto } from 'src/dtos/editing.dto';
import { DB } from 'src/schema';
@@ -9,9 +10,7 @@ import { DB } from 'src/schema';
export class AssetEditRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({
params: [DummyValue.UUID],
})
@GenerateSql({ params: [DummyValue.UUID] })
replaceAll(assetId: string, edits: AssetEditActionItem[]): Promise<AssetEditActionItemResponseDto[]> {
return this.db.transaction().execute(async (trx) => {
await trx.deleteFrom('asset_edit').where('assetId', '=', assetId).execute();
@@ -28,9 +27,7 @@ export class AssetEditRepository {
});
}
@GenerateSql({
params: [DummyValue.UUID],
})
@GenerateSql({ params: [DummyValue.UUID] })
getAll(assetId: string): Promise<AssetEditActionItemResponseDto[]> {
return this.db
.selectFrom('asset_edit')
@@ -39,4 +36,14 @@ export class AssetEditRepository {
.orderBy('sequence', 'asc')
.execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
getWithSyncInfo(assetId: string) {
return this.db
.selectFrom('asset_edit')
.select(columns.syncAssetEdit)
.where('assetId', '=', assetId)
.orderBy('sequence', 'asc')
.execute();
}
}

View File

@@ -107,7 +107,7 @@ export class MediaRepository {
ExposureTime: tags.exposureTime,
ProfileDescription: tags.profileDescription,
ColorSpace: tags.colorspace,
Rating: tags.rating,
Rating: tags.rating === null ? 0 : tags.rating,
// specially convert Orientation to numeric Orientation# for exiftool
'Orientation#': tags.orientation ? Number(tags.orientation) : undefined,
};
@@ -243,23 +243,26 @@ export class MediaRepository {
bitrate: this.parseInt(results.format.bit_rate),
},
videoStreams: results.streams
.filter((stream) => stream.codec_type === 'video')
.filter((stream) => !stream.disposition?.attached_pic)
.map((stream) => ({
index: stream.index,
height: this.parseInt(stream.height),
width: this.parseInt(stream.width),
codecName: stream.codec_name === 'h265' ? 'hevc' : stream.codec_name,
codecType: stream.codec_type,
frameCount: this.parseInt(options?.countFrames ? stream.nb_read_packets : stream.nb_frames),
rotation: this.parseInt(stream.rotation),
isHDR: stream.color_transfer === 'smpte2084' || stream.color_transfer === 'arib-std-b67',
bitrate: this.parseInt(stream.bit_rate),
pixelFormat: stream.pix_fmt || 'yuv420p',
colorPrimaries: stream.color_primaries,
colorSpace: stream.color_space,
colorTransfer: stream.color_transfer,
})),
.filter((stream) => stream.codec_type === 'video' && !stream.disposition?.attached_pic)
.map((stream) => {
const height = this.parseInt(stream.height);
const dar = this.getDar(stream.display_aspect_ratio);
return {
index: stream.index,
height,
width: dar ? Math.round(height * dar) : this.parseInt(stream.width),
codecName: stream.codec_name === 'h265' ? 'hevc' : stream.codec_name,
codecType: stream.codec_type,
frameCount: this.parseInt(options?.countFrames ? stream.nb_read_packets : stream.nb_frames),
rotation: this.parseInt(stream.rotation),
isHDR: stream.color_transfer === 'smpte2084' || stream.color_transfer === 'arib-std-b67',
bitrate: this.parseInt(stream.bit_rate),
pixelFormat: stream.pix_fmt || 'yuv420p',
colorPrimaries: stream.color_primaries,
colorSpace: stream.color_space,
colorTransfer: stream.color_transfer,
};
}),
audioStreams: results.streams
.filter((stream) => stream.codec_type === 'audio')
.map((stream) => ({
@@ -352,4 +355,15 @@ export class MediaRepository {
private parseFloat(value: string | number | undefined): number {
return Number.parseFloat(value as string) || 0;
}
private getDar(dar: string | undefined): number {
if (dar) {
const [darW, darH] = dar.split(':').map(Number);
if (darW && darH) {
return darW / darH;
}
}
return 0;
}
}

View File

@@ -53,6 +53,7 @@ export class SyncRepository {
albumUser: AlbumUserSync;
asset: AssetSync;
assetExif: AssetExifSync;
assetEdit: AssetEditSync;
assetFace: AssetFaceSync;
assetMetadata: AssetMetadataSync;
authUser: AuthUserSync;
@@ -75,6 +76,7 @@ export class SyncRepository {
this.albumUser = new AlbumUserSync(this.db);
this.asset = new AssetSync(this.db);
this.assetExif = new AssetExifSync(this.db);
this.assetEdit = new AssetEditSync(this.db);
this.assetFace = new AssetFaceSync(this.db);
this.assetMetadata = new AssetMetadataSync(this.db);
this.authUser = new AuthUserSync(this.db);
@@ -91,7 +93,7 @@ export class SyncRepository {
}
}
class BaseSync {
export class BaseSync {
constructor(protected db: Kysely<DB>) {}
protected backfillQuery<T extends keyof DB>(t: T, { nowId, beforeUpdateId, afterUpdateId }: SyncBackfillOptions) {
@@ -501,6 +503,30 @@ class AssetExifSync extends BaseSync {
}
}
class AssetEditSync extends BaseSync {
@GenerateSql({ params: [dummyQueryOptions], stream: true })
getDeletes(options: SyncQueryOptions) {
return this.auditQuery('asset_edit_audit', options)
.select(['asset_edit_audit.id', 'editId'])
.innerJoin('asset', 'asset.id', 'asset_edit_audit.assetId')
.where('asset.ownerId', '=', options.userId)
.stream();
}
cleanupAuditTable(daysAgo: number) {
return this.auditCleanup('asset_edit_audit', daysAgo);
}
@GenerateSql({ params: [dummyQueryOptions], stream: true })
getUpserts(options: SyncQueryOptions) {
return this.upsertQuery('asset_edit', options)
.select([...columns.syncAssetEdit, 'asset_edit.updateId'])
.innerJoin('asset', 'asset.id', 'asset_edit.assetId')
.where('asset.ownerId', '=', options.userId)
.stream();
}
}
class MemorySync extends BaseSync {
@GenerateSql({ params: [dummyQueryOptions], stream: true })
getDeletes(options: SyncQueryOptions) {

View File

@@ -11,7 +11,7 @@ import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { NotificationDto } from 'src/dtos/notification.dto';
import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { SyncAssetExifV1, SyncAssetV1 } from 'src/dtos/sync.dto';
import { SyncAssetEditV1, SyncAssetExifV1, SyncAssetV1 } from 'src/dtos/sync.dto';
import { AppRestartEvent, ArgsOf, EventRepository } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { handlePromiseError } from 'src/utils/misc';
@@ -37,7 +37,7 @@ export interface ClientEventMap {
AssetUploadReadyV1: [{ asset: SyncAssetV1; exif: SyncAssetExifV1 }];
AppRestartV1: [AppRestartEvent];
AssetEditReadyV1: [{ asset: SyncAssetV1 }];
AssetEditReadyV1: [{ asset: SyncAssetV1; edit: SyncAssetEditV1[] }];
}
export type AuthFn = (client: Socket) => Promise<AuthDto>;

View File

@@ -286,3 +286,16 @@ export const asset_edit_delete = registerFunction({
END
`,
});
export const asset_edit_audit = registerFunction({
name: 'asset_edit_audit',
returnType: 'TRIGGER',
language: 'PLPGSQL',
body: `
BEGIN
INSERT INTO asset_edit_audit ("editId", "assetId")
SELECT "id", "assetId"
FROM OLD;
RETURN NULL;
END`,
});

View File

@@ -29,6 +29,7 @@ import { AlbumUserTable } from 'src/schema/tables/album-user.table';
import { AlbumTable } from 'src/schema/tables/album.table';
import { ApiKeyTable } from 'src/schema/tables/api-key.table';
import { AssetAuditTable } from 'src/schema/tables/asset-audit.table';
import { AssetEditAuditTable } from 'src/schema/tables/asset-edit-audit.table';
import { AssetEditTable } from 'src/schema/tables/asset-edit.table';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { AssetFaceAuditTable } from 'src/schema/tables/asset-face-audit.table';
@@ -88,6 +89,7 @@ export class ImmichDatabase {
ApiKeyTable,
AssetAuditTable,
AssetEditTable,
AssetEditAuditTable,
AssetFaceTable,
AssetFaceAuditTable,
AssetMetadataTable,
@@ -184,6 +186,7 @@ export interface DB {
asset: AssetTable;
asset_audit: AssetAuditTable;
asset_edit: AssetEditTable;
asset_edit_audit: AssetEditAuditTable;
asset_exif: AssetExifTable;
asset_face: AssetFaceTable;
asset_face_audit: AssetFaceAuditTable;

View File

@@ -0,0 +1,9 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`UPDATE "asset_exif" SET "rating" = NULL WHERE "rating" = 0;`.execute(db);
}
export async function down(): Promise<void> {
// not supported
}

View File

@@ -0,0 +1,53 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE OR REPLACE FUNCTION asset_edit_audit()
RETURNS TRIGGER
LANGUAGE PLPGSQL
AS $$
BEGIN
INSERT INTO asset_edit_audit ("editId", "assetId")
SELECT "id", "assetId"
FROM OLD;
RETURN NULL;
END
$$;`.execute(db);
await sql`CREATE TABLE "asset_edit_audit" (
"id" uuid NOT NULL DEFAULT immich_uuid_v7(),
"editId" uuid NOT NULL,
"assetId" uuid NOT NULL,
"deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp(),
CONSTRAINT "asset_edit_audit_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE INDEX "asset_edit_audit_assetId_idx" ON "asset_edit_audit" ("assetId");`.execute(db);
await sql`CREATE INDEX "asset_edit_audit_deletedAt_idx" ON "asset_edit_audit" ("deletedAt");`.execute(db);
await sql`ALTER TABLE "asset_edit" ADD "updatedAt" timestamp with time zone NOT NULL DEFAULT now();`.execute(db);
await sql`ALTER TABLE "asset_edit" ADD "updateId" uuid NOT NULL DEFAULT immich_uuid_v7();`.execute(db);
await sql`CREATE INDEX "asset_edit_updateId_idx" ON "asset_edit" ("updateId");`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "asset_edit_audit"
AFTER DELETE ON "asset_edit"
REFERENCING OLD TABLE AS "old"
FOR EACH STATEMENT
WHEN (pg_trigger_depth() = 0)
EXECUTE FUNCTION asset_edit_audit();`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "asset_edit_updatedAt"
BEFORE UPDATE ON "asset_edit"
FOR EACH ROW
EXECUTE FUNCTION updated_at();`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_asset_edit_audit', '{"type":"function","name":"asset_edit_audit","sql":"CREATE OR REPLACE FUNCTION asset_edit_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO asset_edit_audit (\\"editId\\", \\"assetId\\")\\n SELECT \\"id\\", \\"assetId\\"\\n FROM OLD;\\n RETURN NULL;\\n END\\n $$;"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_asset_edit_audit', '{"type":"trigger","name":"asset_edit_audit","sql":"CREATE OR REPLACE TRIGGER \\"asset_edit_audit\\"\\n AFTER DELETE ON \\"asset_edit\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() = 0)\\n EXECUTE FUNCTION asset_edit_audit();"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_asset_edit_updatedAt', '{"type":"trigger","name":"asset_edit_updatedAt","sql":"CREATE OR REPLACE TRIGGER \\"asset_edit_updatedAt\\"\\n BEFORE UPDATE ON \\"asset_edit\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();"}'::jsonb);`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP TRIGGER "asset_edit_audit" ON "asset_edit";`.execute(db);
await sql`DROP TRIGGER "asset_edit_updatedAt" ON "asset_edit";`.execute(db);
await sql`DROP INDEX "asset_edit_updateId_idx";`.execute(db);
await sql`ALTER TABLE "asset_edit" DROP COLUMN "updatedAt";`.execute(db);
await sql`ALTER TABLE "asset_edit" DROP COLUMN "updateId";`.execute(db);
await sql`DROP TABLE "asset_edit_audit";`.execute(db);
await sql`DROP FUNCTION asset_edit_audit;`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_asset_edit_audit';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_asset_edit_audit';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_asset_edit_updatedAt';`.execute(db);
}

View File

@@ -0,0 +1,17 @@
import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools';
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
@Table('asset_edit_audit')
export class AssetEditAuditTable {
@PrimaryGeneratedUuidV7Column()
id!: Generated<string>;
@Column({ type: 'uuid' })
editId!: string;
@Column({ type: 'uuid', index: true })
assetId!: string;
@CreateDateColumn({ default: () => 'clock_timestamp()', index: true })
deletedAt!: Generated<Timestamp>;
}

View File

@@ -6,13 +6,17 @@ import {
Generated,
PrimaryGeneratedColumn,
Table,
Timestamp,
Unique,
UpdateDateColumn,
} from '@immich/sql-tools';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AssetEditAction, AssetEditParameters } from 'src/dtos/editing.dto';
import { asset_edit_delete, asset_edit_insert } from 'src/schema/functions';
import { asset_edit_audit, asset_edit_delete, asset_edit_insert } from 'src/schema/functions';
import { AssetTable } from 'src/schema/tables/asset.table';
@Table('asset_edit')
@UpdatedAtTrigger('asset_edit_updatedAt')
@AfterInsertTrigger({ scope: 'statement', function: asset_edit_insert, referencingNewTableAs: 'inserted_edit' })
@AfterDeleteTrigger({
scope: 'statement',
@@ -20,6 +24,12 @@ import { AssetTable } from 'src/schema/tables/asset.table';
referencingOldTableAs: 'deleted_edit',
when: 'pg_trigger_depth() = 0',
})
@AfterDeleteTrigger({
scope: 'statement',
function: asset_edit_audit,
referencingOldTableAs: 'old',
when: 'pg_trigger_depth() = 0',
})
@Unique({ columns: ['assetId', 'sequence'] })
export class AssetEditTable {
@PrimaryGeneratedColumn()
@@ -36,4 +46,10 @@ export class AssetEditTable {
@Column({ type: 'integer' })
sequence!: number;
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
}

View File

@@ -516,7 +516,7 @@ export class AssetService extends BaseService {
dateTimeOriginal?: string;
latitude?: number;
longitude?: number;
rating?: number;
rating?: number | null;
}) {
const { id, description, dateTimeOriginal, latitude, longitude, rating } = dto;
const writes = _.omitBy(
@@ -546,6 +546,7 @@ export class AssetService extends BaseService {
async getAssetEdits(auth: AuthDto, id: string): Promise<AssetEditsResponseDto> {
await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] });
const edits = await this.assetEditRepository.getAll(id);
return {
assetId: id,
edits,

View File

@@ -98,6 +98,7 @@ export class JobService extends BaseService {
case JobName.AssetEditThumbnailGeneration: {
const asset = await this.assetRepository.getById(item.data.id);
const edits = await this.assetEditRepository.getWithSyncInfo(item.data.id);
if (asset) {
this.websocketRepository.clientSend('AssetEditReadyV1', asset.ownerId, {
@@ -122,6 +123,7 @@ export class JobService extends BaseService {
height: asset.height,
isEdited: asset.isEdited,
},
edit: edits,
});
}

View File

@@ -23,10 +23,11 @@ import { MediaService } from 'src/services/media.service';
import { JobCounts, RawImageInfo } from 'src/types';
import { AssetFaceFactory } from 'test/factories/asset-face.factory';
import { AssetFactory } from 'test/factories/asset.factory';
import { PersonFactory } from 'test/factories/person.factory';
import { probeStub } from 'test/fixtures/media.stub';
import { personStub, personThumbnailStub } from 'test/fixtures/person.stub';
import { personThumbnailStub } from 'test/fixtures/person.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { factory } from 'test/small.factory';
import { factory, newUuid } from 'test/small.factory';
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
const fullsizeBuffer = Buffer.from('embedded image data');
@@ -50,9 +51,10 @@ describe(MediaService.name, () => {
describe('handleQueueGenerateThumbnails', () => {
it('should queue all assets', async () => {
const asset = AssetFactory.create();
const person = PersonFactory.create({ faceAssetId: newUuid() });
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset]));
mocks.person.getAll.mockReturnValue(makeStream([personStub.newThumbnail]));
mocks.person.getAll.mockReturnValue(makeStream([person]));
await sut.handleQueueGenerateThumbnails({ force: true });
@@ -68,7 +70,7 @@ describe(MediaService.name, () => {
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{
name: JobName.PersonGenerateThumbnail,
data: { id: personStub.newThumbnail.id },
data: { id: person.id },
},
]);
});
@@ -106,8 +108,13 @@ describe(MediaService.name, () => {
});
it('should queue all people with missing thumbnail path', async () => {
const [person1, person2] = [
PersonFactory.create({ thumbnailPath: undefined }),
PersonFactory.create({ thumbnailPath: undefined }),
];
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([AssetFactory.create()]));
mocks.person.getAll.mockReturnValue(makeStream([personStub.noThumbnail, personStub.noThumbnail]));
mocks.person.getAll.mockReturnValue(makeStream([person1, person2]));
mocks.person.getRandomFace.mockResolvedValueOnce(AssetFaceFactory.create());
await sut.handleQueueGenerateThumbnails({ force: false });
@@ -120,7 +127,7 @@ describe(MediaService.name, () => {
{
name: JobName.PersonGenerateThumbnail,
data: {
id: personStub.newThumbnail.id,
id: person1.id,
},
},
]);
@@ -276,17 +283,17 @@ describe(MediaService.name, () => {
describe('handleQueueMigration', () => {
it('should remove empty directories and queue jobs', async () => {
const asset = AssetFactory.create();
const person = PersonFactory.create();
mocks.assetJob.streamForMigrationJob.mockReturnValue(makeStream([asset]));
mocks.job.getJobCounts.mockResolvedValue({ active: 1, waiting: 0 } as JobCounts);
mocks.person.getAll.mockReturnValue(makeStream([personStub.withName]));
mocks.person.getAll.mockReturnValue(makeStream([person]));
await expect(sut.handleQueueMigration()).resolves.toBe(JobStatus.Success);
expect(mocks.storage.removeEmptyDirs).toHaveBeenCalledTimes(2);
expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.AssetFileMigration, data: { id: asset.id } }]);
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{ name: JobName.PersonFileMigration, data: { id: personStub.withName.id } },
]);
expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.PersonFileMigration, data: { id: person.id } }]);
});
});
@@ -1479,8 +1486,9 @@ describe(MediaService.name, () => {
});
it('should skip a person without a face asset id', async () => {
mocks.person.getById.mockResolvedValue(personStub.noThumbnail);
await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
const person = PersonFactory.create({ faceAssetId: null });
mocks.person.getById.mockResolvedValue(person);
await sut.handleGeneratePersonThumbnail({ id: person.id });
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
});
@@ -1490,17 +1498,17 @@ describe(MediaService.name, () => {
});
it('should generate a thumbnail', async () => {
const person = PersonFactory.create();
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailMiddle);
mocks.media.generateThumbnail.mockResolvedValue();
const data = Buffer.from('');
const info = { width: 1000, height: 1000 } as OutputInfo;
mocks.media.decodeImage.mockResolvedValue({ data, info });
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
JobStatus.Success,
);
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith(personStub.primaryPerson.id);
expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith(person.id);
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String));
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailMiddle.originalPath, {
colorspace: Colorspace.P3,
@@ -1531,21 +1539,21 @@ describe(MediaService.name, () => {
},
expect.any(String),
);
expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', thumbnailPath: expect.any(String) });
expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, thumbnailPath: expect.any(String) });
});
it('should use preview path if video', async () => {
const person = PersonFactory.create();
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.videoThumbnail);
mocks.media.generateThumbnail.mockResolvedValue();
const data = Buffer.from('');
const info = { width: 1000, height: 1000 } as OutputInfo;
mocks.media.decodeImage.mockResolvedValue({ data, info });
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
JobStatus.Success,
);
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith(personStub.primaryPerson.id);
expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith(person.id);
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String));
expect(mocks.media.decodeImage).toHaveBeenCalledWith(expect.any(String), {
colorspace: Colorspace.P3,
@@ -1576,19 +1584,19 @@ describe(MediaService.name, () => {
},
expect.any(String),
);
expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', thumbnailPath: expect.any(String) });
expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, thumbnailPath: expect.any(String) });
});
it('should generate a thumbnail without going negative', async () => {
const person = PersonFactory.create();
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailStart);
mocks.media.generateThumbnail.mockResolvedValue();
const data = Buffer.from('');
const info = { width: 2160, height: 3840 } as OutputInfo;
mocks.media.decodeImage.mockResolvedValue({ data, info });
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
JobStatus.Success,
);
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailStart.originalPath, {
colorspace: Colorspace.P3,
@@ -1622,16 +1630,16 @@ describe(MediaService.name, () => {
});
it('should generate a thumbnail without overflowing', async () => {
const person = PersonFactory.create();
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailEnd);
mocks.person.update.mockResolvedValue(personStub.primaryPerson);
mocks.person.update.mockResolvedValue(person);
mocks.media.generateThumbnail.mockResolvedValue();
const data = Buffer.from('');
const info = { width: 1000, height: 1000 } as OutputInfo;
mocks.media.decodeImage.mockResolvedValue({ data, info });
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
JobStatus.Success,
);
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailEnd.originalPath, {
colorspace: Colorspace.P3,
@@ -1665,16 +1673,16 @@ describe(MediaService.name, () => {
});
it('should handle negative coordinates', async () => {
const person = PersonFactory.create();
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.negativeCoordinate);
mocks.person.update.mockResolvedValue(personStub.primaryPerson);
mocks.person.update.mockResolvedValue(person);
mocks.media.generateThumbnail.mockResolvedValue();
const data = Buffer.from('');
const info = { width: 4624, height: 3080 } as OutputInfo;
mocks.media.decodeImage.mockResolvedValue({ data, info });
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
JobStatus.Success,
);
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.negativeCoordinate.originalPath, {
colorspace: Colorspace.P3,
@@ -1708,16 +1716,16 @@ describe(MediaService.name, () => {
});
it('should handle overflowing coordinate', async () => {
const person = PersonFactory.create();
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.overflowingCoordinate);
mocks.person.update.mockResolvedValue(personStub.primaryPerson);
mocks.person.update.mockResolvedValue(person);
mocks.media.generateThumbnail.mockResolvedValue();
const data = Buffer.from('');
const info = { width: 4624, height: 3080 } as OutputInfo;
mocks.media.decodeImage.mockResolvedValue({ data, info });
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
JobStatus.Success,
);
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.overflowingCoordinate.originalPath, {
colorspace: Colorspace.P3,
@@ -1751,9 +1759,11 @@ describe(MediaService.name, () => {
});
it('should use embedded preview if enabled and raw image', async () => {
const person = PersonFactory.create();
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } });
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.rawEmbeddedThumbnail);
mocks.person.update.mockResolvedValue(personStub.primaryPerson);
mocks.person.update.mockResolvedValue(person);
mocks.media.generateThumbnail.mockResolvedValue();
const extracted = Buffer.from('');
const data = Buffer.from('');
@@ -1762,9 +1772,7 @@ describe(MediaService.name, () => {
mocks.media.decodeImage.mockResolvedValue({ data, info });
mocks.media.getImageMetadata.mockResolvedValue({ width: 2160, height: 3840, isTransparent: false });
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
JobStatus.Success,
);
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
expect(mocks.media.extract).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath);
expect(mocks.media.decodeImage).toHaveBeenCalledWith(extracted, {
@@ -1799,21 +1807,23 @@ describe(MediaService.name, () => {
});
it('should not use embedded preview if enabled and not raw image', async () => {
const person = PersonFactory.create();
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailMiddle);
mocks.media.generateThumbnail.mockResolvedValue();
const data = Buffer.from('');
const info = { width: 2160, height: 3840 } as OutputInfo;
mocks.media.decodeImage.mockResolvedValue({ data, info });
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
JobStatus.Success,
);
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
expect(mocks.media.extract).not.toHaveBeenCalled();
expect(mocks.media.generateThumbnail).toHaveBeenCalled();
});
it('should not use embedded preview if enabled and raw image if not exists', async () => {
const person = PersonFactory.create();
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } });
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.rawEmbeddedThumbnail);
mocks.media.generateThumbnail.mockResolvedValue();
@@ -1821,9 +1831,7 @@ describe(MediaService.name, () => {
const info = { width: 2160, height: 3840 } as OutputInfo;
mocks.media.decodeImage.mockResolvedValue({ data, info });
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
JobStatus.Success,
);
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
expect(mocks.media.extract).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath);
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath, {
@@ -1835,6 +1843,8 @@ describe(MediaService.name, () => {
});
it('should not use embedded preview if enabled and raw image if low resolution', async () => {
const person = PersonFactory.create();
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } });
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.rawEmbeddedThumbnail);
mocks.media.generateThumbnail.mockResolvedValue();
@@ -1845,9 +1855,7 @@ describe(MediaService.name, () => {
mocks.media.extract.mockResolvedValue({ buffer: extracted, format: RawExtractedFormat.Jpeg });
mocks.media.getImageMetadata.mockResolvedValue({ width: 1000, height: 1000, isTransparent: false });
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
JobStatus.Success,
);
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
expect(mocks.media.extract).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath);
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath, {

View File

@@ -16,8 +16,8 @@ import {
import { ImmichTags } from 'src/repositories/metadata.repository';
import { firstDateTime, MetadataService } from 'src/services/metadata.service';
import { AssetFactory } from 'test/factories/asset.factory';
import { PersonFactory } from 'test/factories/person.factory';
import { probeStub } from 'test/fixtures/media.stub';
import { personStub } from 'test/fixtures/person.stub';
import { tagStub } from 'test/fixtures/tag.stub';
import { factory } from 'test/small.factory';
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
@@ -1208,18 +1208,18 @@ describe(MetadataService.name, () => {
it('should apply metadata face tags creating new people', async () => {
const asset = AssetFactory.create();
const person = PersonFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
mockReadTags(makeFaceTags({ Name: personStub.withName.name }));
mockReadTags(makeFaceTags({ Name: person.name }));
mocks.person.getDistinctNames.mockResolvedValue([]);
mocks.person.createAll.mockResolvedValue([personStub.withName.id]);
mocks.person.update.mockResolvedValue(personStub.withName);
mocks.person.createAll.mockResolvedValue([person.id]);
mocks.person.update.mockResolvedValue(person);
await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(asset.ownerId, { withHidden: true });
expect(mocks.person.createAll).toHaveBeenCalledWith([
expect.objectContaining({ name: personStub.withName.name }),
]);
expect(mocks.person.createAll).toHaveBeenCalledWith([expect.objectContaining({ name: person.name })]);
expect(mocks.person.refreshFaces).toHaveBeenCalledWith(
[
{
@@ -1243,19 +1243,21 @@ describe(MetadataService.name, () => {
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{
name: JobName.PersonGenerateThumbnail,
data: { id: personStub.withName.id },
data: { id: person.id },
},
]);
});
it('should assign metadata face tags to existing persons', async () => {
const asset = AssetFactory.create();
const person = PersonFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
mockReadTags(makeFaceTags({ Name: personStub.withName.name }));
mocks.person.getDistinctNames.mockResolvedValue([{ id: personStub.withName.id, name: personStub.withName.name }]);
mockReadTags(makeFaceTags({ Name: person.name }));
mocks.person.getDistinctNames.mockResolvedValue([{ id: person.id, name: person.name }]);
mocks.person.createAll.mockResolvedValue([]);
mocks.person.update.mockResolvedValue(personStub.withName);
mocks.person.update.mockResolvedValue(person);
await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(asset.ownerId, { withHidden: true });
@@ -1265,7 +1267,7 @@ describe(MetadataService.name, () => {
{
id: 'random-uuid',
assetId: asset.id,
personId: personStub.withName.id,
personId: person.id,
imageHeight: 100,
imageWidth: 1000,
boundingBoxX1: 0,
@@ -1335,21 +1337,20 @@ describe(MetadataService.name, () => {
async ({ orientation, expected }) => {
const { imgW, imgH, x1, x2, y1, y2 } = expected;
const asset = AssetFactory.create();
const person = PersonFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
mockReadTags(makeFaceTags({ Name: personStub.withName.name }, orientation));
mockReadTags(makeFaceTags({ Name: person.name }, orientation));
mocks.person.getDistinctNames.mockResolvedValue([]);
mocks.person.createAll.mockResolvedValue([personStub.withName.id]);
mocks.person.update.mockResolvedValue(personStub.withName);
mocks.person.createAll.mockResolvedValue([person.id]);
mocks.person.update.mockResolvedValue(person);
await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(asset.ownerId, {
withHidden: true,
});
expect(mocks.person.createAll).toHaveBeenCalledWith([
expect.objectContaining({ name: personStub.withName.name }),
]);
expect(mocks.person.createAll).toHaveBeenCalledWith([expect.objectContaining({ name: person.name })]);
expect(mocks.person.refreshFaces).toHaveBeenCalledWith(
[
{
@@ -1373,7 +1374,7 @@ describe(MetadataService.name, () => {
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{
name: JobName.PersonGenerateThumbnail,
data: { id: personStub.withName.id },
data: { id: person.id },
},
]);
},
@@ -1422,6 +1423,20 @@ describe(MetadataService.name, () => {
);
});
it('should handle 0 as unrated -> null', async () => {
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mockReadTags({ Rating: 0 });
await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({
rating: null,
}),
{ lockedPropertiesBehavior: 'skip' },
);
});
it('should handle valid negative rating value', async () => {
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
@@ -1779,6 +1794,28 @@ describe(MetadataService.name, () => {
'timeZone',
]);
});
it('should write rating', async () => {
const asset = factory.jobAssets.sidecarWrite();
asset.exifInfo.rating = 4;
mocks.assetJob.getLockedPropertiesForMetadataExtraction.mockResolvedValue(['rating']);
mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(asset);
await expect(sut.handleSidecarWrite({ id: asset.id })).resolves.toBe(JobStatus.Success);
expect(mocks.metadata.writeTags).toHaveBeenCalledWith(asset.files[0].path, { Rating: 4 });
expect(mocks.asset.unlockProperties).toHaveBeenCalledWith(asset.id, ['rating']);
});
it('should write null rating as 0', async () => {
const asset = factory.jobAssets.sidecarWrite();
asset.exifInfo.rating = null;
mocks.assetJob.getLockedPropertiesForMetadataExtraction.mockResolvedValue(['rating']);
mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(asset);
await expect(sut.handleSidecarWrite({ id: asset.id })).resolves.toBe(JobStatus.Success);
expect(mocks.metadata.writeTags).toHaveBeenCalledWith(asset.files[0].path, { Rating: 0 });
expect(mocks.asset.unlockProperties).toHaveBeenCalledWith(asset.id, ['rating']);
});
});
describe('firstDateTime', () => {

View File

@@ -301,7 +301,7 @@ export class MetadataService extends BaseService {
// comments
description: String(exifTags.ImageDescription || exifTags.Description || '').trim(),
profileDescription: exifTags.ProfileDescription || null,
rating: validateRange(exifTags.Rating, -1, 5),
rating: exifTags.Rating === 0 ? null : validateRange(exifTags.Rating, -1, 5),
// grouping
livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null,
@@ -451,7 +451,7 @@ export class MetadataService extends BaseService {
dateTimeOriginal: asset.exifInfo.dateTimeOriginal as string | null,
latitude: asset.exifInfo.latitude,
longitude: asset.exifInfo.longitude,
rating: asset.exifInfo.rating,
rating: asset.exifInfo.rating ?? 0,
tags: asset.exifInfo.tags,
timeZone: asset.exifInfo.timeZone,
},

View File

@@ -1,6 +1,6 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
import { mapFaces, mapPerson, PersonResponseDto } from 'src/dtos/person.dto';
import { mapFaces, mapPerson } from 'src/dtos/person.dto';
import { AssetFileType, CacheControl, JobName, JobStatus, SourceType, SystemMetadataKey } from 'src/enum';
import { FaceSearchResult } from 'src/repositories/search.repository';
import { PersonService } from 'src/services/person.service';
@@ -11,25 +11,11 @@ import { AuthFactory } from 'test/factories/auth.factory';
import { PersonFactory } from 'test/factories/person.factory';
import { UserFactory } from 'test/factories/user.factory';
import { authStub } from 'test/fixtures/auth.stub';
import { personStub } from 'test/fixtures/person.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { getAsDetectedFace, getForFacialRecognitionJob } from 'test/mappers';
import { newDate, newUuid } from 'test/small.factory';
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
const responseDto: PersonResponseDto = {
id: 'person-1',
name: 'Person 1',
birthDate: null,
thumbnailPath: '/path/to/thumbnail.jpg',
isHidden: false,
updatedAt: expect.any(Date),
isFavorite: false,
color: expect.any(String),
};
const statistics = { assets: 3 };
describe(PersonService.name, () => {
let sut: PersonService;
let mocks: ServiceMocks;
@@ -44,60 +30,54 @@ describe(PersonService.name, () => {
describe('getAll', () => {
it('should get all hidden and visible people with thumbnails', async () => {
const auth = AuthFactory.create();
const [person, hiddenPerson] = [PersonFactory.create(), PersonFactory.create({ isHidden: true })];
mocks.person.getAllForUser.mockResolvedValue({
items: [personStub.withName, personStub.hidden],
items: [person, hiddenPerson],
hasNextPage: false,
});
mocks.person.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 });
await expect(sut.getAll(authStub.admin, { withHidden: true, page: 1, size: 10 })).resolves.toEqual({
await expect(sut.getAll(auth, { withHidden: true, page: 1, size: 10 })).resolves.toEqual({
hasNextPage: false,
total: 2,
hidden: 1,
people: [
responseDto,
{
id: 'person-1',
name: '',
birthDate: null,
thumbnailPath: '/path/to/thumbnail.jpg',
expect.objectContaining({ id: person.id, isHidden: false }),
expect.objectContaining({
id: hiddenPerson.id,
isHidden: true,
isFavorite: false,
updatedAt: expect.any(Date),
color: expect.any(String),
},
}),
],
});
expect(mocks.person.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, authStub.admin.user.id, {
expect(mocks.person.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, auth.user.id, {
minimumFaceCount: 3,
withHidden: true,
});
});
it('should get all visible people and favorites should be first in the array', async () => {
const auth = AuthFactory.create();
const [isFavorite, person] = [PersonFactory.create({ isFavorite: true }), PersonFactory.create()];
mocks.person.getAllForUser.mockResolvedValue({
items: [personStub.isFavorite, personStub.withName],
items: [isFavorite, person],
hasNextPage: false,
});
mocks.person.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 });
await expect(sut.getAll(authStub.admin, { withHidden: false, page: 1, size: 10 })).resolves.toEqual({
await expect(sut.getAll(auth, { withHidden: false, page: 1, size: 10 })).resolves.toEqual({
hasNextPage: false,
total: 2,
hidden: 1,
people: [
{
id: 'person-4',
name: personStub.isFavorite.name,
birthDate: null,
thumbnailPath: '/path/to/thumbnail.jpg',
isHidden: false,
expect.objectContaining({
id: isFavorite.id,
isFavorite: true,
updatedAt: expect.any(Date),
color: personStub.isFavorite.color,
},
responseDto,
}),
expect.objectContaining({ id: person.id, isFavorite: false }),
],
});
expect(mocks.person.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, authStub.admin.user.id, {
expect(mocks.person.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, auth.user.id, {
minimumFaceCount: 3,
withHidden: false,
});
@@ -106,71 +86,89 @@ describe(PersonService.name, () => {
describe('getById', () => {
it('should require person.read permission', async () => {
mocks.person.getById.mockResolvedValue(personStub.withName);
await expect(sut.getById(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
const auth = AuthFactory.create();
const person = PersonFactory.create();
mocks.person.getById.mockResolvedValue(person);
await expect(sut.getById(auth, person.id)).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
});
it('should throw a bad request when person is not found', async () => {
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
await expect(sut.getById(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
const auth = AuthFactory.create();
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['unknown']));
await expect(sut.getById(auth, 'unknown')).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set(['unknown']));
});
it('should get a person by id', async () => {
mocks.person.getById.mockResolvedValue(personStub.withName);
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
await expect(sut.getById(authStub.admin, 'person-1')).resolves.toEqual(responseDto);
expect(mocks.person.getById).toHaveBeenCalledWith('person-1');
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
const auth = AuthFactory.create();
const person = PersonFactory.create();
mocks.person.getById.mockResolvedValue(person);
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
await expect(sut.getById(auth, person.id)).resolves.toEqual(expect.objectContaining({ id: person.id }));
expect(mocks.person.getById).toHaveBeenCalledWith(person.id);
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
});
});
describe('getThumbnail', () => {
it('should require person.read permission', async () => {
mocks.person.getById.mockResolvedValue(personStub.noName);
await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException);
const auth = AuthFactory.create();
const person = PersonFactory.create();
mocks.person.getById.mockResolvedValue(person);
await expect(sut.getThumbnail(auth, person.id)).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.storage.createReadStream).not.toHaveBeenCalled();
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
});
it('should throw an error when personId is invalid', async () => {
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(NotFoundException);
const auth = AuthFactory.create();
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['unknown']));
await expect(sut.getThumbnail(auth, 'unknown')).rejects.toBeInstanceOf(NotFoundException);
expect(mocks.storage.createReadStream).not.toHaveBeenCalled();
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set(['unknown']));
});
it('should throw an error when person has no thumbnail', async () => {
mocks.person.getById.mockResolvedValue(personStub.noThumbnail);
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(NotFoundException);
const auth = AuthFactory.create();
const person = PersonFactory.create({ thumbnailPath: '' });
mocks.person.getById.mockResolvedValue(person);
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
await expect(sut.getThumbnail(auth, person.id)).rejects.toBeInstanceOf(NotFoundException);
expect(mocks.storage.createReadStream).not.toHaveBeenCalled();
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
});
it('should serve the thumbnail', async () => {
mocks.person.getById.mockResolvedValue(personStub.noName);
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
await expect(sut.getThumbnail(authStub.admin, 'person-1')).resolves.toEqual(
const auth = AuthFactory.create();
const person = PersonFactory.create();
mocks.person.getById.mockResolvedValue(person);
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
await expect(sut.getThumbnail(auth, person.id)).resolves.toEqual(
new ImmichFileResponse({
path: '/path/to/thumbnail.jpg',
path: person.thumbnailPath,
contentType: 'image/jpeg',
cacheControl: CacheControl.PrivateWithoutCache,
}),
);
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
});
});
describe('update', () => {
it('should require person.write permission', async () => {
mocks.person.getById.mockResolvedValue(personStub.noName);
await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).rejects.toBeInstanceOf(
BadRequestException,
);
const auth = AuthFactory.create();
const person = PersonFactory.create();
mocks.person.getById.mockResolvedValue(person);
await expect(sut.update(auth, person.id, { name: 'Person 1' })).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.person.update).not.toHaveBeenCalled();
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
});
it('should throw an error when personId is invalid', async () => {
@@ -183,86 +181,108 @@ describe(PersonService.name, () => {
});
it("should update a person's name", async () => {
mocks.person.update.mockResolvedValue(personStub.withName);
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
const auth = AuthFactory.create();
const person = PersonFactory.create({ name: 'Person 1' });
await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).resolves.toEqual(responseDto);
mocks.person.update.mockResolvedValue(person);
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', name: 'Person 1' });
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
await expect(sut.update(auth, person.id, { name: 'Person 1' })).resolves.toEqual(
expect.objectContaining({ id: person.id, name: 'Person 1' }),
);
expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, name: 'Person 1' });
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
});
it("should update a person's date of birth", async () => {
mocks.person.update.mockResolvedValue(personStub.withBirthDate);
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
const auth = AuthFactory.create();
const person = PersonFactory.create({ birthDate: new Date('1976-06-30') });
await expect(sut.update(authStub.admin, 'person-1', { birthDate: new Date('1976-06-30') })).resolves.toEqual({
id: 'person-1',
name: 'Person 1',
mocks.person.update.mockResolvedValue(person);
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
await expect(sut.update(auth, person.id, { birthDate: new Date('1976-06-30') })).resolves.toEqual({
id: person.id,
name: person.name,
birthDate: '1976-06-30',
thumbnailPath: '/path/to/thumbnail.jpg',
thumbnailPath: person.thumbnailPath,
isHidden: false,
isFavorite: false,
updatedAt: expect.any(Date),
color: expect.any(String),
});
expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: new Date('1976-06-30') });
expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, birthDate: new Date('1976-06-30') });
expect(mocks.job.queue).not.toHaveBeenCalled();
expect(mocks.job.queueAll).not.toHaveBeenCalled();
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
});
it('should update a person visibility', async () => {
mocks.person.update.mockResolvedValue(personStub.withName);
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
const auth = AuthFactory.create();
const person = PersonFactory.create({ isHidden: true });
await expect(sut.update(authStub.admin, 'person-1', { isHidden: false })).resolves.toEqual(responseDto);
mocks.person.update.mockResolvedValue(person);
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', isHidden: false });
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
await expect(sut.update(auth, person.id, { isHidden: true })).resolves.toEqual(
expect.objectContaining({ isHidden: true }),
);
expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, isHidden: true });
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
});
it('should update a person favorite status', async () => {
mocks.person.update.mockResolvedValue(personStub.withName);
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
const auth = AuthFactory.create();
const person = PersonFactory.create({ isFavorite: true });
await expect(sut.update(authStub.admin, 'person-1', { isFavorite: true })).resolves.toEqual(responseDto);
mocks.person.update.mockResolvedValue(person);
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', isFavorite: true });
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
await expect(sut.update(auth, person.id, { isFavorite: true })).resolves.toEqual(
expect.objectContaining({ isFavorite: true }),
);
expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, isFavorite: true });
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
});
it("should update a person's thumbnailPath", async () => {
const face = AssetFaceFactory.create();
const auth = AuthFactory.create();
mocks.person.update.mockResolvedValue(personStub.withName);
const person = PersonFactory.create();
mocks.person.update.mockResolvedValue(person);
mocks.person.getForFeatureFaceUpdate.mockResolvedValue(face);
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([face.assetId]));
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
await expect(sut.update(auth, 'person-1', { featureFaceAssetId: face.assetId })).resolves.toEqual(responseDto);
await expect(sut.update(auth, person.id, { featureFaceAssetId: face.assetId })).resolves.toEqual(
expect.objectContaining({ id: person.id }),
);
expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', faceAssetId: face.id });
expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, faceAssetId: face.id });
expect(mocks.person.getForFeatureFaceUpdate).toHaveBeenCalledWith({
assetId: face.assetId,
personId: 'person-1',
personId: person.id,
});
expect(mocks.job.queue).toHaveBeenCalledWith({
name: JobName.PersonGenerateThumbnail,
data: { id: 'person-1' },
data: { id: person.id },
});
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set(['person-1']));
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
});
it('should throw an error when the face feature assetId is invalid', async () => {
mocks.person.getById.mockResolvedValue(personStub.withName);
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
const auth = AuthFactory.create();
const person = PersonFactory.create();
await expect(sut.update(authStub.admin, 'person-1', { featureFaceAssetId: '-1' })).rejects.toThrow(
BadRequestException,
);
mocks.person.getById.mockResolvedValue(person);
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
await expect(sut.update(auth, person.id, { featureFaceAssetId: '-1' })).rejects.toThrow(BadRequestException);
expect(mocks.person.update).not.toHaveBeenCalled();
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
});
});
@@ -283,36 +303,39 @@ describe(PersonService.name, () => {
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set());
await expect(
sut.reassignFaces(authStub.admin, personStub.noName.id, {
sut.reassignFaces(AuthFactory.create(), 'person-id', {
data: [{ personId: 'asset-face-1', assetId: '' }],
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.job.queue).not.toHaveBeenCalledWith();
expect(mocks.job.queueAll).not.toHaveBeenCalledWith();
});
it('should reassign a face', async () => {
const face = AssetFaceFactory.create();
const auth = AuthFactory.create();
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.withName.id]));
mocks.person.getById.mockResolvedValue(personStub.noName);
const person = PersonFactory.create();
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
mocks.person.getById.mockResolvedValue(person);
mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([face.id]));
mocks.person.getFacesByIds.mockResolvedValue([face]);
mocks.person.reassignFace.mockResolvedValue(1);
mocks.person.getRandomFace.mockResolvedValue(AssetFaceFactory.create());
mocks.person.refreshFaces.mockResolvedValue();
mocks.person.reassignFace.mockResolvedValue(5);
mocks.person.update.mockResolvedValue(personStub.noName);
mocks.person.update.mockResolvedValue(person);
await expect(
sut.reassignFaces(auth, personStub.noName.id, {
data: [{ personId: personStub.withName.id, assetId: face.assetId }],
sut.reassignFaces(auth, person.id, {
data: [{ personId: person.id, assetId: face.assetId }],
}),
).resolves.toBeDefined();
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{
name: JobName.PersonGenerateThumbnail,
data: { id: personStub.newThumbnail.id },
data: { id: person.id },
},
]);
});
@@ -320,7 +343,7 @@ describe(PersonService.name, () => {
describe('handlePersonMigration', () => {
it('should not move person files', async () => {
await expect(sut.handlePersonMigration(personStub.noName)).resolves.toBe(JobStatus.Failed);
await expect(sut.handlePersonMigration(PersonFactory.create())).resolves.toBe(JobStatus.Failed);
});
});
@@ -347,12 +370,14 @@ describe(PersonService.name, () => {
describe('createNewFeaturePhoto', () => {
it('should change person feature photo', async () => {
const person = PersonFactory.create();
mocks.person.getRandomFace.mockResolvedValue(AssetFaceFactory.create());
await sut.createNewFeaturePhoto([personStub.newThumbnail.id]);
await sut.createNewFeaturePhoto([person.id]);
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{
name: JobName.PersonGenerateThumbnail,
data: { id: personStub.newThumbnail.id },
data: { id: person.id },
},
]);
});
@@ -361,23 +386,22 @@ describe(PersonService.name, () => {
describe('reassignFacesById', () => {
it('should create a new person', async () => {
const face = AssetFaceFactory.create();
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id]));
const person = PersonFactory.create();
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([face.id]));
mocks.person.getFaceById.mockResolvedValue(face);
mocks.person.reassignFace.mockResolvedValue(1);
mocks.person.getById.mockResolvedValue(personStub.noName);
await expect(sut.reassignFacesById(AuthFactory.create(), personStub.noName.id, { id: face.id })).resolves.toEqual(
{
birthDate: personStub.noName.birthDate,
isHidden: personStub.noName.isHidden,
isFavorite: personStub.noName.isFavorite,
id: personStub.noName.id,
name: personStub.noName.name,
thumbnailPath: personStub.noName.thumbnailPath,
updatedAt: expect.any(Date),
color: personStub.noName.color,
},
);
mocks.person.getById.mockResolvedValue(person);
await expect(sut.reassignFacesById(AuthFactory.create(), person.id, { id: face.id })).resolves.toEqual({
birthDate: person.birthDate,
isHidden: person.isHidden,
isFavorite: person.isFavorite,
id: person.id,
name: person.name,
thumbnailPath: person.thumbnailPath,
updatedAt: expect.any(Date),
});
expect(mocks.job.queue).not.toHaveBeenCalledWith();
expect(mocks.job.queueAll).not.toHaveBeenCalledWith();
@@ -385,12 +409,14 @@ describe(PersonService.name, () => {
it('should fail if user has not the correct permissions on the asset', async () => {
const face = AssetFaceFactory.create();
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id]));
const person = PersonFactory.create();
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
mocks.person.getFaceById.mockResolvedValue(face);
mocks.person.reassignFace.mockResolvedValue(1);
mocks.person.getById.mockResolvedValue(personStub.noName);
mocks.person.getById.mockResolvedValue(person);
await expect(
sut.reassignFacesById(AuthFactory.create(), personStub.noName.id, {
sut.reassignFacesById(AuthFactory.create(), person.id, {
id: face.id,
}),
).rejects.toBeInstanceOf(BadRequestException);
@@ -402,22 +428,25 @@ describe(PersonService.name, () => {
describe('createPerson', () => {
it('should create a new person', async () => {
mocks.person.create.mockResolvedValue(personStub.primaryPerson);
const auth = AuthFactory.create();
await expect(sut.create(authStub.admin, {})).resolves.toBeDefined();
mocks.person.create.mockResolvedValue(PersonFactory.create());
await expect(sut.create(auth, {})).resolves.toBeDefined();
expect(mocks.person.create).toHaveBeenCalledWith({ ownerId: authStub.admin.user.id });
expect(mocks.person.create).toHaveBeenCalledWith({ ownerId: auth.user.id });
});
});
describe('handlePersonCleanup', () => {
it('should delete people without faces', async () => {
mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.noName]);
const person = PersonFactory.create();
mocks.person.getAllWithoutFaces.mockResolvedValue([person]);
await sut.handlePersonCleanup();
expect(mocks.person.delete).toHaveBeenCalledWith([personStub.noName.id]);
expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.noName.thumbnailPath);
expect(mocks.person.delete).toHaveBeenCalledWith([person.id]);
expect(mocks.storage.unlink).toHaveBeenCalledWith(person.thumbnailPath);
});
});
@@ -449,15 +478,17 @@ describe(PersonService.name, () => {
it('should queue all assets', async () => {
const asset = AssetFactory.create();
const person = PersonFactory.create();
mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([asset]));
mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.withName]);
mocks.person.getAllWithoutFaces.mockResolvedValue([person]);
await sut.handleQueueDetectFaces({ force: true });
expect(mocks.person.deleteFaces).toHaveBeenCalledWith({ sourceType: SourceType.MachineLearning });
expect(mocks.person.delete).toHaveBeenCalledWith([personStub.withName.id]);
expect(mocks.person.delete).toHaveBeenCalledWith([person.id]);
expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: true });
expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.withName.thumbnailPath);
expect(mocks.storage.unlink).toHaveBeenCalledWith(person.thumbnailPath);
expect(mocks.assetJob.streamForDetectFacesJob).toHaveBeenCalledWith(true);
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{
@@ -490,10 +521,12 @@ describe(PersonService.name, () => {
it('should delete existing people and faces if forced', async () => {
const asset = AssetFactory.create();
const face = AssetFaceFactory.from().person().build();
mocks.person.getAll.mockReturnValue(makeStream([face.person!, personStub.randomPerson]));
const person = PersonFactory.create();
mocks.person.getAll.mockReturnValue(makeStream([face.person!, person]));
mocks.person.getAllFaces.mockReturnValue(makeStream([face]));
mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([asset]));
mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]);
mocks.person.getAllWithoutFaces.mockResolvedValue([person]);
mocks.person.deleteFaces.mockResolvedValue();
await sut.handleQueueDetectFaces({ force: true });
@@ -505,8 +538,8 @@ describe(PersonService.name, () => {
data: { id: asset.id },
},
]);
expect(mocks.person.delete).toHaveBeenCalledWith([personStub.randomPerson.id]);
expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.randomPerson.thumbnailPath);
expect(mocks.person.delete).toHaveBeenCalledWith([person.id]);
expect(mocks.storage.unlink).toHaveBeenCalledWith(person.thumbnailPath);
expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: true });
});
});
@@ -661,6 +694,8 @@ describe(PersonService.name, () => {
it('should delete existing people if forced', async () => {
const face = AssetFaceFactory.from().person().build();
const person = PersonFactory.create();
mocks.job.getJobCounts.mockResolvedValue({
active: 1,
waiting: 0,
@@ -669,9 +704,9 @@ describe(PersonService.name, () => {
failed: 0,
delayed: 0,
});
mocks.person.getAll.mockReturnValue(makeStream([face.person!, personStub.randomPerson]));
mocks.person.getAll.mockReturnValue(makeStream([face.person!, person]));
mocks.person.getAllFaces.mockReturnValue(makeStream([face]));
mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]);
mocks.person.getAllWithoutFaces.mockResolvedValue([person]);
mocks.person.unassignFaces.mockResolvedValue();
await sut.handleQueueRecognizeFaces({ force: true });
@@ -684,8 +719,8 @@ describe(PersonService.name, () => {
data: { id: face.id, deferred: false },
},
]);
expect(mocks.person.delete).toHaveBeenCalledWith([personStub.randomPerson.id]);
expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.randomPerson.thumbnailPath);
expect(mocks.person.delete).toHaveBeenCalledWith([person.id]);
expect(mocks.storage.unlink).toHaveBeenCalledWith(person.thumbnailPath);
expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: false });
});
});
@@ -1059,59 +1094,71 @@ describe(PersonService.name, () => {
describe('mergePerson', () => {
it('should require person.write and person.merge permission', async () => {
mocks.person.getById.mockResolvedValueOnce(personStub.primaryPerson);
mocks.person.getById.mockResolvedValueOnce(personStub.mergePerson);
const auth = AuthFactory.create();
const [person, mergePerson] = [PersonFactory.create(), PersonFactory.create()];
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).rejects.toBeInstanceOf(
mocks.person.getById.mockResolvedValueOnce(person);
mocks.person.getById.mockResolvedValueOnce(mergePerson);
await expect(sut.mergePerson(auth, person.id, { ids: [mergePerson.id] })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(mocks.person.reassignFaces).not.toHaveBeenCalled();
expect(mocks.person.delete).not.toHaveBeenCalled();
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
});
it('should merge two people without smart merge', async () => {
mocks.person.getById.mockResolvedValueOnce(personStub.primaryPerson);
mocks.person.getById.mockResolvedValueOnce(personStub.mergePerson);
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1']));
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2']));
const auth = AuthFactory.create();
const [person, mergePerson] = [PersonFactory.create(), PersonFactory.create()];
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([
{ id: 'person-2', success: true },
mocks.person.getById.mockResolvedValueOnce(person);
mocks.person.getById.mockResolvedValueOnce(mergePerson);
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([person.id]));
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([mergePerson.id]));
await expect(sut.mergePerson(auth, person.id, { ids: [mergePerson.id] })).resolves.toEqual([
{ id: mergePerson.id, success: true },
]);
expect(mocks.person.reassignFaces).toHaveBeenCalledWith({
newPersonId: personStub.primaryPerson.id,
oldPersonId: personStub.mergePerson.id,
newPersonId: person.id,
oldPersonId: mergePerson.id,
});
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
});
it('should merge two people with smart merge', async () => {
mocks.person.getById.mockResolvedValueOnce(personStub.randomPerson);
mocks.person.getById.mockResolvedValueOnce(personStub.primaryPerson);
mocks.person.update.mockResolvedValue({ ...personStub.randomPerson, name: personStub.primaryPerson.name });
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-3']));
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1']));
const auth = AuthFactory.create();
const [person, mergePerson] = [
PersonFactory.create({ name: undefined }),
PersonFactory.create({ name: 'Merge person' }),
];
await expect(sut.mergePerson(authStub.admin, 'person-3', { ids: ['person-1'] })).resolves.toEqual([
{ id: 'person-1', success: true },
mocks.person.getById.mockResolvedValueOnce(person);
mocks.person.getById.mockResolvedValueOnce(mergePerson);
mocks.person.update.mockResolvedValue({ ...person, name: mergePerson.name });
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([person.id]));
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([mergePerson.id]));
await expect(sut.mergePerson(auth, person.id, { ids: [mergePerson.id] })).resolves.toEqual([
{ id: mergePerson.id, success: true },
]);
expect(mocks.person.reassignFaces).toHaveBeenCalledWith({
newPersonId: personStub.randomPerson.id,
oldPersonId: personStub.primaryPerson.id,
newPersonId: person.id,
oldPersonId: mergePerson.id,
});
expect(mocks.person.update).toHaveBeenCalledWith({
id: personStub.randomPerson.id,
name: personStub.primaryPerson.name,
id: person.id,
name: mergePerson.name,
});
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
});
it('should throw an error when the primary person is not found', async () => {
@@ -1126,48 +1173,60 @@ describe(PersonService.name, () => {
});
it('should handle invalid merge ids', async () => {
mocks.person.getById.mockResolvedValueOnce(personStub.primaryPerson);
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1']));
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2']));
const auth = AuthFactory.create();
const person = PersonFactory.create();
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([
{ id: 'person-2', success: false, error: BulkIdErrorReason.NOT_FOUND },
mocks.person.getById.mockResolvedValueOnce(person);
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([person.id]));
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['unknown']));
await expect(sut.mergePerson(auth, person.id, { ids: ['unknown'] })).resolves.toEqual([
{ id: 'unknown', success: false, error: BulkIdErrorReason.NOT_FOUND },
]);
expect(mocks.person.reassignFaces).not.toHaveBeenCalled();
expect(mocks.person.delete).not.toHaveBeenCalled();
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
});
it('should handle an error reassigning faces', async () => {
mocks.person.getById.mockResolvedValueOnce(personStub.primaryPerson);
mocks.person.getById.mockResolvedValueOnce(personStub.mergePerson);
mocks.person.reassignFaces.mockRejectedValue(new Error('update failed'));
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1']));
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2']));
const auth = AuthFactory.create();
const [person, mergePerson] = [PersonFactory.create(), PersonFactory.create()];
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([
{ id: 'person-2', success: false, error: BulkIdErrorReason.UNKNOWN },
mocks.person.getById.mockResolvedValueOnce(person);
mocks.person.getById.mockResolvedValueOnce(mergePerson);
mocks.person.reassignFaces.mockRejectedValue(new Error('update failed'));
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([person.id]));
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([mergePerson.id]));
await expect(sut.mergePerson(auth, person.id, { ids: [mergePerson.id] })).resolves.toEqual([
{ id: mergePerson.id, success: false, error: BulkIdErrorReason.UNKNOWN },
]);
expect(mocks.person.delete).not.toHaveBeenCalled();
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
});
});
describe('getStatistics', () => {
it('should get correct number of person', async () => {
mocks.person.getById.mockResolvedValue(personStub.primaryPerson);
mocks.person.getStatistics.mockResolvedValue(statistics);
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
await expect(sut.getStatistics(authStub.admin, 'person-1')).resolves.toEqual({ assets: 3 });
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
const auth = AuthFactory.create();
const person = PersonFactory.create();
mocks.person.getById.mockResolvedValue(person);
mocks.person.getStatistics.mockResolvedValue({ assets: 3 });
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
await expect(sut.getStatistics(auth, person.id)).resolves.toEqual({ assets: 3 });
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
});
it('should require person.read permission', async () => {
mocks.person.getById.mockResolvedValue(personStub.primaryPerson);
await expect(sut.getStatistics(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
const auth = AuthFactory.create();
const person = PersonFactory.create();
mocks.person.getById.mockResolvedValue(person);
await expect(sut.getStatistics(auth, person.id)).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
});
});

View File

@@ -595,7 +595,7 @@ export class PersonService extends BaseService {
update.birthDate = mergePerson.birthDate;
}
if (Object.keys(update).length > 0) {
if (Object.keys(update).length > 1) {
primaryPerson = await this.personRepository.update(update);
}

View File

@@ -5,7 +5,6 @@ import { SearchService } from 'src/services/search.service';
import { AssetFactory } from 'test/factories/asset.factory';
import { AuthFactory } from 'test/factories/auth.factory';
import { authStub } from 'test/fixtures/auth.stub';
import { personStub } from 'test/fixtures/person.stub';
import { newTestService, ServiceMocks } from 'test/utils';
import { beforeEach, vitest } from 'vitest';
@@ -26,17 +25,18 @@ describe(SearchService.name, () => {
describe('searchPerson', () => {
it('should pass options to search', async () => {
const { name } = personStub.withName;
const auth = AuthFactory.create();
const name = 'foo';
mocks.person.getByName.mockResolvedValue([]);
await sut.searchPerson(authStub.user1, { name, withHidden: false });
await sut.searchPerson(auth, { name, withHidden: false });
expect(mocks.person.getByName).toHaveBeenCalledWith(authStub.user1.user.id, name, { withHidden: false });
expect(mocks.person.getByName).toHaveBeenCalledWith(auth.user.id, name, { withHidden: false });
await sut.searchPerson(authStub.user1, { name, withHidden: true });
await sut.searchPerson(auth, { name, withHidden: true });
expect(mocks.person.getByName).toHaveBeenCalledWith(authStub.user1.user.id, name, { withHidden: true });
expect(mocks.person.getByName).toHaveBeenCalledWith(auth.user.id, name, { withHidden: true });
});
});

View File

@@ -89,6 +89,7 @@ export const SYNC_TYPES_ORDER = [
SyncRequestType.AssetFacesV2,
SyncRequestType.UserMetadataV1,
SyncRequestType.AssetMetadataV1,
SyncRequestType.AssetEditsV1,
];
const throwSessionRequired = () => {
@@ -175,6 +176,7 @@ export class SyncService extends BaseService {
[SyncRequestType.PartnersV1]: () => this.syncPartnersV1(options, response, checkpointMap),
[SyncRequestType.AssetsV1]: () => this.syncAssetsV1(options, response, checkpointMap),
[SyncRequestType.AssetExifsV1]: () => this.syncAssetExifsV1(options, response, checkpointMap),
[SyncRequestType.AssetEditsV1]: () => this.syncAssetEditsV1(options, response, checkpointMap),
[SyncRequestType.PartnerAssetsV1]: () => this.syncPartnerAssetsV1(options, response, checkpointMap, session.id),
[SyncRequestType.AssetMetadataV1]: () => this.syncAssetMetadataV1(options, response, checkpointMap, auth),
[SyncRequestType.PartnerAssetExifsV1]: () =>
@@ -215,6 +217,7 @@ export class SyncService extends BaseService {
await this.syncRepository.asset.cleanupAuditTable(pruneThreshold);
await this.syncRepository.assetFace.cleanupAuditTable(pruneThreshold);
await this.syncRepository.assetMetadata.cleanupAuditTable(pruneThreshold);
await this.syncRepository.assetEdit.cleanupAuditTable(pruneThreshold);
await this.syncRepository.memory.cleanupAuditTable(pruneThreshold);
await this.syncRepository.memoryToAsset.cleanupAuditTable(pruneThreshold);
await this.syncRepository.partner.cleanupAuditTable(pruneThreshold);
@@ -352,6 +355,21 @@ export class SyncService extends BaseService {
}
}
private async syncAssetEditsV1(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) {
const deleteType = SyncEntityType.AssetEditDeleteV1;
const deletes = this.syncRepository.assetEdit.getDeletes({ ...options, ack: checkpointMap[deleteType] });
for await (const { id, ...data } of deletes) {
send(response, { type: deleteType, ids: [id], data });
}
const upsertType = SyncEntityType.AssetEditV1;
const upserts = this.syncRepository.assetEdit.getUpserts({ ...options, ack: checkpointMap[upsertType] });
for await (const { updateId, ...data } of upserts) {
send(response, { type: upsertType, ids: [updateId], data });
}
}
private async syncPartnerAssetExifsV1(
options: SyncQueryOptions,
response: Writable,

View File

@@ -4,7 +4,7 @@ import { AssetEditTable } from 'src/schema/tables/asset-edit.table';
import { AssetFactory } from 'test/factories/asset.factory';
import { build } from 'test/factories/builder.factory';
import { AssetEditLike, AssetLike, FactoryBuilder } from 'test/factories/types';
import { newUuid } from 'test/small.factory';
import { newDate, newUuid } from 'test/small.factory';
export class AssetEditFactory {
private constructor(private readonly value: Selectable<AssetEditTable>) {}
@@ -15,6 +15,7 @@ export class AssetEditFactory {
static from(dto: AssetEditLike = {}) {
const id = dto.id ?? newUuid();
const updateId = dto.updateId ?? newUuid();
return new AssetEditFactory({
id,
@@ -22,6 +23,8 @@ export class AssetEditFactory {
action: AssetEditAction.Crop,
parameters: { x: 5, y: 6, width: 200, height: 100 },
sequence: 1,
updateId,
updatedAt: newDate(),
...dto,
});
}

View File

@@ -2,171 +2,6 @@ import { AssetFileType, AssetType } from 'src/enum';
import { AssetFileFactory } from 'test/factories/asset-file.factory';
import { userStub } from 'test/fixtures/user.stub';
const updateId = '0d1173e3-4d80-4d76-b41e-57d56de21125';
export const personStub = {
noName: Object.freeze({
id: 'person-1',
createdAt: new Date('2021-01-01'),
updatedAt: new Date('2021-01-01'),
updateId,
ownerId: userStub.admin.id,
name: '',
birthDate: null,
thumbnailPath: '/path/to/thumbnail.jpg',
faces: [],
faceAssetId: null,
faceAsset: null,
isHidden: false,
isFavorite: false,
color: 'red',
}),
hidden: Object.freeze({
id: 'person-1',
createdAt: new Date('2021-01-01'),
updatedAt: new Date('2021-01-01'),
updateId,
ownerId: userStub.admin.id,
name: '',
birthDate: null,
thumbnailPath: '/path/to/thumbnail.jpg',
faces: [],
faceAssetId: null,
faceAsset: null,
isHidden: true,
isFavorite: false,
color: 'red',
}),
withName: Object.freeze({
id: 'person-1',
createdAt: new Date('2021-01-01'),
updatedAt: new Date('2021-01-01'),
updateId,
ownerId: userStub.admin.id,
name: 'Person 1',
birthDate: null,
thumbnailPath: '/path/to/thumbnail.jpg',
faces: [],
faceAssetId: 'assetFaceId',
faceAsset: null,
isHidden: false,
isFavorite: false,
color: 'red',
}),
withBirthDate: Object.freeze({
id: 'person-1',
createdAt: new Date('2021-01-01'),
updatedAt: new Date('2021-01-01'),
updateId,
ownerId: userStub.admin.id,
name: 'Person 1',
birthDate: new Date('1976-06-30'),
thumbnailPath: '/path/to/thumbnail.jpg',
faces: [],
faceAssetId: null,
faceAsset: null,
isHidden: false,
isFavorite: false,
color: 'red',
}),
noThumbnail: Object.freeze({
id: 'person-1',
createdAt: new Date('2021-01-01'),
updatedAt: new Date('2021-01-01'),
updateId,
ownerId: userStub.admin.id,
name: '',
birthDate: null,
thumbnailPath: '',
faces: [],
faceAssetId: null,
faceAsset: null,
isHidden: false,
isFavorite: false,
color: 'red',
}),
newThumbnail: Object.freeze({
id: 'person-1',
createdAt: new Date('2021-01-01'),
updatedAt: new Date('2021-01-01'),
updateId,
ownerId: userStub.admin.id,
name: '',
birthDate: null,
thumbnailPath: '/new/path/to/thumbnail.jpg',
faces: [],
faceAssetId: 'asset-id',
faceAsset: null,
isHidden: false,
isFavorite: false,
color: 'red',
}),
primaryPerson: Object.freeze({
id: 'person-1',
createdAt: new Date('2021-01-01'),
updatedAt: new Date('2021-01-01'),
updateId,
ownerId: userStub.admin.id,
name: 'Person 1',
birthDate: null,
thumbnailPath: '/path/to/thumbnail',
faces: [],
faceAssetId: null,
faceAsset: null,
isHidden: false,
isFavorite: false,
color: 'red',
}),
mergePerson: Object.freeze({
id: 'person-2',
createdAt: new Date('2021-01-01'),
updatedAt: new Date('2021-01-01'),
updateId,
ownerId: userStub.admin.id,
name: 'Person 2',
birthDate: null,
thumbnailPath: '/path/to/thumbnail',
faces: [],
faceAssetId: null,
faceAsset: null,
isHidden: false,
isFavorite: false,
color: 'red',
}),
randomPerson: Object.freeze({
id: 'person-3',
createdAt: new Date('2021-01-01'),
updatedAt: new Date('2021-01-01'),
updateId,
ownerId: userStub.admin.id,
name: '',
birthDate: null,
thumbnailPath: '/path/to/thumbnail',
faces: [],
faceAssetId: null,
faceAsset: null,
isHidden: false,
isFavorite: false,
color: 'red',
}),
isFavorite: Object.freeze({
id: 'person-4',
createdAt: new Date('2021-01-01'),
updatedAt: new Date('2021-01-01'),
updateId,
ownerId: userStub.admin.id,
name: 'Person 1',
birthDate: null,
thumbnailPath: '/path/to/thumbnail.jpg',
faces: [],
faceAssetId: 'assetFaceId',
faceAsset: null,
isHidden: false,
isFavorite: true,
color: 'red',
}),
};
export const personThumbnailStub = {
newThumbnailStart: Object.freeze({
ownerId: userStub.admin.id,

View File

@@ -1,3 +1,5 @@
import { AssetEditAction } from 'src/dtos/editing.dto';
import { AssetEditRepository } from 'src/repositories/asset-edit.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { PartnerRepository } from 'src/repositories/partner.repository';
import { UserRepository } from 'src/repositories/user.repository';
@@ -45,6 +47,27 @@ describe('audit', () => {
});
});
describe('asset_edit_audit', () => {
it('should not cascade asset deletes to asset_edit_audit', async () => {
const assetEditRepo = ctx.get(AssetEditRepository);
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await assetEditRepo.replaceAll(asset.id, [
{
action: AssetEditAction.Crop,
parameters: { x: 10, y: 20, width: 100, height: 200 },
},
]);
await ctx.database.deleteFrom('asset').where('id', '=', asset.id).execute();
await expect(
ctx.database.selectFrom('asset_edit_audit').select(['id']).where('assetId', '=', asset.id).execute(),
).resolves.toHaveLength(0);
});
});
describe('assets_audit', () => {
it('should not cascade user deletes to assets_audit', async () => {
const userRepo = ctx.get(UserRepository);

View File

@@ -1,9 +1,10 @@
import { schemaFromCode } from '@immich/sql-tools';
import { Kysely } from 'kysely';
import { DateTime } from 'luxon';
import { AssetMetadataKey, UserMetadataKey } from 'src/enum';
import { DatabaseRepository } from 'src/repositories/database.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { SyncRepository } from 'src/repositories/sync.repository';
import { BaseSync, SyncRepository } from 'src/repositories/sync.repository';
import { DB } from 'src/schema';
import { SyncService } from 'src/services/sync.service';
import { newMediumService } from 'test/medium.factory';
@@ -222,5 +223,21 @@ describe(SyncService.name, () => {
expect(after).toHaveLength(1);
expect(after[0].id).toBe(keep.id);
});
it('should cleanup every table', async () => {
const { sut } = setup();
const auditTables = schemaFromCode()
.tables.filter((table) => table.name.endsWith('_audit'))
.map(({ name }) => name);
const auditCleanupSpy = vi.spyOn(BaseSync.prototype as any, 'auditCleanup');
await expect(sut.onAuditTableCleanup()).resolves.toBeUndefined();
expect(auditCleanupSpy).toHaveBeenCalledTimes(auditTables.length);
for (const table of auditTables) {
expect(auditCleanupSpy, `Audit table ${table} was not cleaned up`).toHaveBeenCalledWith(table, 31);
}
});
});
});

View File

@@ -0,0 +1,300 @@
import { Kysely } from 'kysely';
import { AssetEditAction, MirrorAxis } from 'src/dtos/editing.dto';
import { SyncEntityType, SyncRequestType } from 'src/enum';
import { AssetEditRepository } from 'src/repositories/asset-edit.repository';
import { DB } from 'src/schema';
import { SyncTestContext } from 'test/medium.factory';
import { factory } from 'test/small.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = async (db?: Kysely<DB>) => {
const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser();
return { auth, user, session, ctx };
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(SyncRequestType.AssetEditsV1, () => {
it('should detect and sync the first asset edit', async () => {
const { auth, ctx } = await setup();
const { asset } = await ctx.newAsset({ ownerId: auth.user.id });
const assetEditRepo = ctx.get(AssetEditRepository);
await assetEditRepo.replaceAll(asset.id, [
{
action: AssetEditAction.Crop,
parameters: { x: 10, y: 20, width: 100, height: 200 },
},
]);
const response = await ctx.syncStream(auth, [SyncRequestType.AssetEditsV1]);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
id: expect.any(String),
assetId: asset.id,
action: AssetEditAction.Crop,
parameters: { x: 10, y: 20, width: 100, height: 200 },
sequence: 0,
},
type: SyncEntityType.AssetEditV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetEditsV1]);
});
it('should detect and sync multiple asset edits for the same asset', async () => {
const { auth, ctx } = await setup();
const { asset } = await ctx.newAsset({ ownerId: auth.user.id });
const assetEditRepo = ctx.get(AssetEditRepository);
await assetEditRepo.replaceAll(asset.id, [
{
action: AssetEditAction.Crop,
parameters: { x: 10, y: 20, width: 100, height: 200 },
},
{
action: AssetEditAction.Rotate,
parameters: { angle: 90 },
},
{
action: AssetEditAction.Mirror,
parameters: { axis: MirrorAxis.Horizontal },
},
]);
const response = await ctx.syncStream(auth, [SyncRequestType.AssetEditsV1]);
expect(response).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: {
id: expect.any(String),
assetId: asset.id,
action: AssetEditAction.Crop,
parameters: { x: 10, y: 20, width: 100, height: 200 },
sequence: 0,
},
type: SyncEntityType.AssetEditV1,
},
{
ack: expect.any(String),
data: {
id: expect.any(String),
assetId: asset.id,
action: AssetEditAction.Rotate,
parameters: { angle: 90 },
sequence: 1,
},
type: SyncEntityType.AssetEditV1,
},
{
ack: expect.any(String),
data: {
id: expect.any(String),
assetId: asset.id,
action: AssetEditAction.Mirror,
parameters: { axis: MirrorAxis.Horizontal },
sequence: 2,
},
type: SyncEntityType.AssetEditV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]),
);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetEditsV1]);
});
it('should detect and sync updated edits', async () => {
const { auth, ctx } = await setup();
const { asset } = await ctx.newAsset({ ownerId: auth.user.id });
const assetEditRepo = ctx.get(AssetEditRepository);
// Create initial edit
const edits = await assetEditRepo.replaceAll(asset.id, [
{
action: AssetEditAction.Crop,
parameters: { x: 10, y: 20, width: 100, height: 200 },
},
]);
const response1 = await ctx.syncStream(auth, [SyncRequestType.AssetEditsV1]);
await ctx.syncAckAll(auth, response1);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetEditsV1]);
// update the existing edit
await ctx.database
.updateTable('asset_edit')
.set({
parameters: { x: 50, y: 60, width: 150, height: 250 },
})
.where('id', '=', edits[0].id)
.execute();
const response2 = await ctx.syncStream(auth, [SyncRequestType.AssetEditsV1]);
expect(response2).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: {
id: expect.any(String),
assetId: asset.id,
action: AssetEditAction.Crop,
parameters: { x: 50, y: 60, width: 150, height: 250 },
sequence: 0,
},
type: SyncEntityType.AssetEditV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]),
);
await ctx.syncAckAll(auth, response2);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetEditsV1]);
});
it('should detect and sync deleted asset edits', async () => {
const { auth, ctx } = await setup();
const { asset } = await ctx.newAsset({ ownerId: auth.user.id });
const assetEditRepo = ctx.get(AssetEditRepository);
// Create initial edit
const edits = await assetEditRepo.replaceAll(asset.id, [
{
action: AssetEditAction.Crop,
parameters: { x: 10, y: 20, width: 100, height: 200 },
},
]);
const response1 = await ctx.syncStream(auth, [SyncRequestType.AssetEditsV1]);
await ctx.syncAckAll(auth, response1);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetEditsV1]);
// Delete all edits
await assetEditRepo.replaceAll(asset.id, []);
const response2 = await ctx.syncStream(auth, [SyncRequestType.AssetEditsV1]);
expect(response2).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: {
editId: edits[0].id,
},
type: SyncEntityType.AssetEditDeleteV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]),
);
await ctx.syncAckAll(auth, response2);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetEditsV1]);
});
it('should only sync asset edits for own user', async () => {
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user2.id });
const assetEditRepo = ctx.get(AssetEditRepository);
const { session } = await ctx.newSession({ userId: user2.id });
const auth2 = factory.auth({ session, user: user2 });
await assetEditRepo.replaceAll(asset.id, [
{
action: AssetEditAction.Crop,
parameters: { x: 10, y: 20, width: 100, height: 200 },
},
]);
// User 2 should see their own edit
await expect(ctx.syncStream(auth2, [SyncRequestType.AssetEditsV1])).resolves.toEqual([
expect.objectContaining({ type: SyncEntityType.AssetEditV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
// User 1 should not see user 2's edit
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetEditsV1]);
});
it('should sync edits for multiple assets', async () => {
const { auth, ctx } = await setup();
const { asset: asset1 } = await ctx.newAsset({ ownerId: auth.user.id });
const { asset: asset2 } = await ctx.newAsset({ ownerId: auth.user.id });
const assetEditRepo = ctx.get(AssetEditRepository);
await assetEditRepo.replaceAll(asset1.id, [
{
action: AssetEditAction.Crop,
parameters: { x: 10, y: 20, width: 100, height: 200 },
},
]);
await assetEditRepo.replaceAll(asset2.id, [
{
action: AssetEditAction.Rotate,
parameters: { angle: 270 },
},
]);
const response = await ctx.syncStream(auth, [SyncRequestType.AssetEditsV1]);
expect(response).toEqual(
expect.arrayContaining([
{
ack: expect.any(String),
data: {
id: expect.any(String),
assetId: asset1.id,
action: AssetEditAction.Crop,
parameters: { x: 10, y: 20, width: 100, height: 200 },
sequence: 0,
},
type: SyncEntityType.AssetEditV1,
},
{
ack: expect.any(String),
data: {
id: expect.any(String),
assetId: asset2.id,
action: AssetEditAction.Rotate,
parameters: { angle: 270 },
sequence: 0,
},
type: SyncEntityType.AssetEditV1,
},
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]),
);
await ctx.syncAckAll(auth, response);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetEditsV1]);
});
it('should not sync edits for partner assets', async () => {
const { auth, ctx } = await setup();
const { user: partner } = await ctx.newUser();
await ctx.newPartner({ sharedById: partner.id, sharedWithId: auth.user.id });
const { asset } = await ctx.newAsset({ ownerId: partner.id });
const assetEditRepo = ctx.get(AssetEditRepository);
await assetEditRepo.replaceAll(asset.id, [
{
action: AssetEditAction.Crop,
parameters: { x: 10, y: 20, width: 100, height: 200 },
},
]);
// Should not see partner's asset edits in own sync
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetEditsV1]);
});
});

View File

@@ -17,10 +17,9 @@
const rateAsset = async (rating: number | null) => {
try {
const updateAssetDto = rating === null ? {} : { rating };
await updateAsset({
id: asset.id,
updateAssetDto,
updateAssetDto: { rating },
});
asset = {

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import StarRating from '$lib/elements/StarRating.svelte';
import StarRating, { type Rating } from '$lib/elements/StarRating.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { preferences } from '$lib/stores/user.store';
import { handlePromiseError } from '$lib/utils';
@@ -14,9 +14,9 @@
let { asset, isOwner }: Props = $props();
let rating = $derived(asset.exifInfo?.rating || 0);
let rating = $derived(asset.exifInfo?.rating || null) as Rating;
const handleChangeRating = async (rating: number) => {
const handleChangeRating = async (rating: number | null) => {
try {
await updateAsset({ id: asset.id, updateAssetDto: { rating } });
} catch (error) {
@@ -26,7 +26,7 @@
</script>
{#if !authManager.isSharedLink && $preferences?.ratings.enabled}
<section class="px-4 pt-2">
<section class="px-4 pt-4">
<StarRating {rating} readOnly={!isOwner} onRating={(rating) => handlePromiseError(handleChangeRating(rating))} />
</section>
{/if}

View File

@@ -1,13 +1,14 @@
<script lang="ts">
import { page } from '$app/state';
import { t } from 'svelte-i18n';
</script>
<svelte:head>
<title>Oops! Error - Immich</title>
<title>{$t('error')} - Immich</title>
</svelte:head>
<section class="flex flex-col px-4 h-dvh w-dvw place-content-center place-items-center">
<h1 class="py-10 text-4xl text-primary">Page not found :/</h1>
<h1 class="py-10 text-4xl text-primary">{$t('errors.page_not_found')}</h1>
{#if page.error?.message}
<h2 class="text-xl text-immich-fg dark:text-immich-dark-fg">{page.error.message}</h2>
{/if}

View File

@@ -11,15 +11,17 @@
<script lang="ts">
import { afterNavigate } from '$app/navigation';
import OnEvents from '$lib/components/OnEvents.svelte';
import { Theme } from '$lib/constants';
import { OpenQueryParam, Theme } from '$lib/constants';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import { themeManager } from '$lib/managers/theme-manager.svelte';
import MapSettingsModal from '$lib/modals/MapSettingsModal.svelte';
import { Route } from '$lib/route';
import { mapSettings } from '$lib/stores/preferences.store';
import { user } from '$lib/stores/user.store';
import { getAssetMediaUrl, handlePromiseError } from '$lib/utils';
import { getMapMarkers, type MapMarkerResponseDto } from '@immich/sdk';
import { Icon, modalManager } from '@immich/ui';
import { mdiCog, mdiMap, mdiMapMarker } from '@mdi/js';
import { Icon, Link, modalManager, Text } from '@immich/ui';
import { mdiCog, mdiInformationOutline, mdiMap, mdiMapMarker } from '@mdi/js';
import type { Feature, GeoJsonProperties, Geometry, Point } from 'geojson';
import { isEqual, omit } from 'lodash-es';
import { DateTime, Duration } from 'luxon';
@@ -301,109 +303,133 @@
<OnEvents {onAssetsDelete} />
<!-- We handle style loading ourselves so we set style blank here -->
<MapLibre
{hash}
style=""
class="h-full {rounded ? 'rounded-2xl' : 'rounded-none'}"
{zoom}
{center}
bounds={initialBounds}
fitBoundsOptions={{ padding: 50, maxZoom: 15 }}
attributionControl={false}
diffStyleUpdates={true}
onload={(event: Map) => {
event.setMaxZoom(18);
event.on('click', handleMapClick);
if (!simplified) {
event.addControl(new GlobeControl(), 'top-left');
}
}}
bind:map
>
{#snippet children({ map }: { map: Map })}
{#if showSimpleControls}
<NavigationControl position="top-left" showCompass={!simplified} />
<!-- Use svelte:boundary instead of MapLibre onerror until https://github.com/dimfeld/svelte-maplibre/issues/279 is fixed -->
<svelte:boundary>
<!-- We handle style loading ourselves so we set style blank here -->
<MapLibre
{hash}
style=""
class="h-full {rounded ? 'rounded-2xl' : 'rounded-none'}"
{zoom}
{center}
bounds={initialBounds}
fitBoundsOptions={{ padding: 50, maxZoom: 15 }}
attributionControl={false}
diffStyleUpdates={true}
onload={(event: Map) => {
event.setMaxZoom(18);
event.on('click', handleMapClick);
if (!simplified) {
event.addControl(new GlobeControl(), 'top-left');
}
}}
bind:map
>
{#snippet children({ map }: { map: Map })}
{#if showSimpleControls}
<NavigationControl position="top-left" showCompass={!simplified} />
{#if !simplified}
<GeolocateControl position="top-left" />
<FullscreenControl position="top-left" />
<ScaleControl />
<AttributionControl compact={false} />
{#if !simplified}
<GeolocateControl position="top-left" />
<FullscreenControl position="top-left" />
<ScaleControl />
<AttributionControl compact={false} />
{/if}
{/if}
{/if}
{#if showSettings}
<Control>
<ControlGroup>
<ControlButton onclick={handleSettingsClick}>
<Icon icon={mdiCog} size="100%" class="text-black/80" />
</ControlButton>
</ControlGroup>
</Control>
{/if}
{#if showSettings}
<Control>
<ControlGroup>
<ControlButton onclick={handleSettingsClick}>
<Icon icon={mdiCog} size="100%" class="text-black/80" />
</ControlButton>
</ControlGroup>
</Control>
{/if}
{#if onOpenInMapView && showSimpleControls}
<Control position="top-right">
<ControlGroup>
<ControlButton onclick={() => onOpenInMapView()}>
<Icon title={$t('open_in_map_view')} icon={mdiMap} size="100%" class="text-black/80" />
</ControlButton>
</ControlGroup>
</Control>
{/if}
{#if onOpenInMapView && showSimpleControls}
<Control position="top-right">
<ControlGroup>
<ControlButton onclick={() => onOpenInMapView()}>
<Icon title={$t('open_in_map_view')} icon={mdiMap} size="100%" class="text-black/80" />
</ControlButton>
</ControlGroup>
</Control>
{/if}
<GeoJSON
data={{
type: 'FeatureCollection',
features: mapMarkers?.map((marker) => asFeature(marker)) ?? [],
}}
id="geojson"
cluster={{ radius: 35, maxZoom: 18 }}
>
<MarkerLayer
applyToClusters
asButton
onclick={(event) => handlePromiseError(handleClusterClick(event.feature.properties?.cluster_id, map))}
>
{#snippet children({ feature })}
<div
class="rounded-full w-10 h-10 bg-immich-primary text-white flex justify-center items-center font-mono font-bold shadow-lg hover:bg-immich-dark-primary transition-all duration-200 hover:text-immich-dark-bg opacity-90"
>
{feature.properties?.point_count?.toLocaleString()}
</div>
{/snippet}
</MarkerLayer>
<MarkerLayer
applyToClusters={false}
asButton
onclick={(event) => {
if (!popup) {
handleAssetClick(event.feature.properties?.id, map);
}
<GeoJSON
data={{
type: 'FeatureCollection',
features: mapMarkers?.map((marker) => asFeature(marker)) ?? [],
}}
id="geojson"
cluster={{ radius: 35, maxZoom: 18 }}
>
{#snippet children({ feature }: { feature: Feature })}
{#if useLocationPin}
<Icon icon={mdiMapMarker} size="50px" class="text-primary -translate-y-[50%]" />
{:else}
<img
src={getAssetMediaUrl({ id: feature.properties?.id })}
class="rounded-full w-15 h-15 border-2 border-immich-primary shadow-lg hover:border-immich-dark-primary transition-all duration-200 hover:scale-150 object-cover bg-immich-primary"
alt={feature.properties?.city && feature.properties.country
? $t('map_marker_for_images', {
values: { city: feature.properties.city, country: feature.properties.country },
})
: $t('map_marker_with_image')}
/>
{/if}
{#if popup}
<Popup offset={[0, -30]} openOn="click" closeOnClickOutside>
{@render popup?.({ marker: asMarker(feature) })}
</Popup>
{/if}
{/snippet}
</MarkerLayer>
</GeoJSON>
<MarkerLayer
applyToClusters
asButton
onclick={(event) => handlePromiseError(handleClusterClick(event.feature.properties?.cluster_id, map))}
>
{#snippet children({ feature })}
<div
class="rounded-full w-10 h-10 bg-immich-primary text-white flex justify-center items-center font-mono font-bold shadow-lg hover:bg-immich-dark-primary transition-all duration-200 hover:text-immich-dark-bg opacity-90"
>
{feature.properties?.point_count?.toLocaleString()}
</div>
{/snippet}
</MarkerLayer>
<MarkerLayer
applyToClusters={false}
asButton
onclick={(event) => {
if (!popup) {
handleAssetClick(event.feature.properties?.id, map);
}
}}
>
{#snippet children({ feature }: { feature: Feature })}
{#if useLocationPin}
<Icon icon={mdiMapMarker} size="50px" class="text-primary -translate-y-[50%]" />
{:else}
<img
src={getAssetMediaUrl({ id: feature.properties?.id })}
class="rounded-full w-15 h-15 border-2 border-immich-primary shadow-lg hover:border-immich-dark-primary transition-all duration-200 hover:scale-150 object-cover bg-immich-primary"
alt={feature.properties?.city && feature.properties.country
? $t('map_marker_for_images', {
values: { city: feature.properties.city, country: feature.properties.country },
})
: $t('map_marker_with_image')}
/>
{/if}
{#if popup}
<Popup offset={[0, -30]} openOn="click" closeOnClickOutside>
{@render popup?.({ marker: asMarker(feature) })}
</Popup>
{/if}
{/snippet}
</MarkerLayer>
</GeoJSON>
{/snippet}
</MapLibre>
{#snippet failed(_)}
<div
class={[
'flex place-content-center place-items-center text-warning',
simplified ? 'gap-4 px-6 text-sm' : 'h-full mx-auto gap-6',
]}
>
<div>
<Icon icon={mdiInformationOutline} size={simplified ? '18' : '24'} />
</div>
<div>
<Text>
{$t('errors.enable_webgl_for_map', { values: { isAdmin: $user.isAdmin } })}
</Text>
{#if $user.isAdmin}
<Link href={Route.systemSettings({ isOpen: OpenQueryParam.LOCATION })}>{$t('go_to_settings')}</Link>
{/if}
</div>
</div>
{/snippet}
</MapLibre>
</svelte:boundary>

View File

@@ -4,13 +4,13 @@
import Combobox from '../combobox.svelte';
interface Props {
rating?: number;
rating?: number | null;
}
let { rating = $bindable() }: Props = $props();
const options = [
{ value: '0', label: $t('rating_count', { values: { count: 0 } }) },
{ value: 'null', label: $t('rating_count', { values: { count: 0 } }) },
{ value: '1', label: $t('rating_count', { values: { count: 1 } }) },
{ value: '2', label: $t('rating_count', { values: { count: 2 } }) },
{ value: '3', label: $t('rating_count', { values: { count: 3 } }) },
@@ -26,7 +26,7 @@
placeholder={$t('search_rating')}
hideLabel
{options}
selectedOption={rating === undefined ? undefined : options[rating]}
selectedOption={rating === undefined ? undefined : options[rating === null ? 0 : rating]}
onSelect={(r) => (rating = r === undefined ? undefined : Number.parseInt(r.value))}
/>
</div>

View File

@@ -65,6 +65,7 @@ export enum OpenQueryParam {
OAUTH = 'oauth',
JOB = 'job',
STORAGE_TEMPLATE = 'storage-template',
LOCATION = 'location',
NOTIFICATIONS = 'notifications',
PURCHASE_SETTINGS = 'user-purchase-settings',
}

View File

@@ -3,27 +3,28 @@
import { shortcuts } from '$lib/actions/shortcut';
import { generateId } from '$lib/utils/generate-id';
import { Icon } from '@immich/ui';
import { mdiStar, mdiStarOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
export type Rating = 1 | 2 | 3 | 4 | 5 | null;
interface Props {
count?: number;
rating: number;
rating: Rating;
readOnly?: boolean;
onRating: (rating: number) => void | undefined;
onRating: (rating: Rating) => void | undefined;
}
let { count = 5, rating, readOnly = false, onRating }: Props = $props();
let ratingSelection = $derived(rating);
let hoverRating = $state(0);
let focusRating = $state(0);
let hoverRating: Rating = $state(null);
let focusRating: Rating = $state(null);
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const starIcon =
'M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.007 5.404.433c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.433 2.082-5.006z';
const id = generateId();
const handleSelect = (newRating: number) => {
const handleSelect = (newRating: Rating) => {
if (readOnly) {
return;
}
@@ -35,7 +36,7 @@
onRating(newRating);
};
const setHoverRating = (value: number) => {
const setHoverRating = (value: Rating) => {
if (readOnly) {
return;
}
@@ -43,11 +44,11 @@
};
const reset = () => {
setHoverRating(0);
focusRating = 0;
setHoverRating(null);
focusRating = null;
};
const handleSelectDebounced = (value: number) => {
const handleSelectDebounced = (value: Rating) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
handleSelect(value);
@@ -58,7 +59,7 @@
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
<fieldset
class="text-primary w-fit cursor-default"
onmouseleave={() => setHoverRating(0)}
onmouseleave={() => setHoverRating(null)}
use:focusOutside={{ onFocusOut: reset }}
use:shortcuts={[
{ shortcut: { key: 'ArrowLeft' }, preventDefault: false, onShortcut: (event) => event.stopPropagation() },
@@ -69,7 +70,7 @@
<div class="flex flex-row" data-testid="star-container">
{#each { length: count } as _, index (index)}
{@const value = index + 1}
{@const filled = hoverRating >= value || (hoverRating === 0 && ratingSelection >= value)}
{@const filled = hoverRating === null ? (ratingSelection || 0) >= value : hoverRating >= value}
{@const starId = `${id}-${value}`}
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
@@ -77,19 +78,12 @@
for={starId}
class:cursor-pointer={!readOnly}
class:ring-2={focusRating === value}
onmouseover={() => setHoverRating(value)}
onmouseover={() => setHoverRating(value as Rating)}
tabindex={-1}
data-testid="star"
>
<span class="sr-only">{$t('rating_count', { values: { count: value } })}</span>
<Icon
icon={starIcon}
size="1.5em"
strokeWidth={1}
color={filled ? 'currentcolor' : 'transparent'}
strokeColor={filled ? 'currentcolor' : '#c1cce8'}
aria-hidden
/>
<Icon icon={filled ? mdiStar : mdiStarOutline} size="1.5em" aria-hidden />
</label>
<input
type="radio"
@@ -99,19 +93,19 @@
bind:group={ratingSelection}
disabled={readOnly}
onfocus={() => {
focusRating = value;
focusRating = value as Rating;
}}
onchange={() => handleSelectDebounced(value)}
onchange={() => handleSelectDebounced(value as Rating)}
class="sr-only"
/>
{/each}
</div>
</fieldset>
{#if ratingSelection > 0 && !readOnly}
{#if ratingSelection !== null && !readOnly}
<button
type="button"
onclick={() => {
ratingSelection = 0;
ratingSelection = null;
handleSelect(ratingSelection);
}}
class="cursor-pointer text-xs text-primary"

View File

@@ -15,7 +15,7 @@
date: SearchDateFilter;
display: SearchDisplayFilters;
mediaType: MediaType;
rating?: number;
rating?: number | null;
};
</script>

View File

@@ -12,6 +12,7 @@ import {
type MaintenanceStatusResponseDto,
type NotificationDto,
type ServerVersionResponseDto,
type SyncAssetEditV1,
type SyncAssetV1,
} from '@immich/sdk';
import { io, type Socket } from 'socket.io-client';
@@ -41,7 +42,7 @@ export interface Events {
AppRestartV1: (event: AppRestartEvent) => void;
MaintenanceStatusV1: (event: MaintenanceStatusResponseDto) => void;
AssetEditReadyV1: (data: { asset: SyncAssetV1 }) => void;
AssetEditReadyV1: (data: { asset: SyncAssetV1; edit: SyncAssetEditV1[] }) => void;
}
const websocket: Socket<Events> = io({

View File

@@ -276,6 +276,8 @@
{#await getTagNames(value) then tagNames}
{tagNames}
{/await}
{:else if searchKey === 'rating'}
{$t('rating_count', { values: { count: value ?? 0 } })}
{:else if value === null || value === ''}
{$t('unknown')}
{:else}