mirror of
https://github.com/immich-app/immich
synced 2025-10-17 18:19:27 +00:00
Merge 98a4d208c2 into 3174a27902
This commit is contained in:
commit
913494da44
15 changed files with 274 additions and 19 deletions
|
|
@ -4,6 +4,7 @@ import {
|
|||
AssetVisibility,
|
||||
deleteAssets,
|
||||
LoginResponseDto,
|
||||
PersonResponseDto,
|
||||
updateAsset,
|
||||
} from '@immich/sdk';
|
||||
import { DateTime } from 'luxon';
|
||||
|
|
@ -700,4 +701,112 @@ describe('/search', () => {
|
|||
expect(status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /search/metadata with personIds and searchOnlyThem', () => {
|
||||
let assetStrictMatch: AssetMediaResponseDto;
|
||||
let assetExtraPerson: AssetMediaResponseDto;
|
||||
let personA: PersonResponseDto;
|
||||
let personB: PersonResponseDto;
|
||||
let personC: PersonResponseDto;
|
||||
let personD: PersonResponseDto;
|
||||
let numberOfAssets: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
personA = await utils.createPerson(admin.accessToken, { name: 'Person A' });
|
||||
personB = await utils.createPerson(admin.accessToken, { name: 'Person B' });
|
||||
personC = await utils.createPerson(admin.accessToken, { name: 'Person C' });
|
||||
personD = await utils.createPerson(admin.accessToken, { name: 'Person D' });
|
||||
|
||||
// Image with exactly A + B
|
||||
assetStrictMatch = await utils.createAsset(admin.accessToken);
|
||||
await utils.createFace({ assetId: assetStrictMatch.id, personId: personA.id });
|
||||
await utils.createFace({ assetId: assetStrictMatch.id, personId: personB.id });
|
||||
|
||||
// Image with A + B + C (should not match strict search)
|
||||
assetExtraPerson = await utils.createAsset(admin.accessToken);
|
||||
await utils.createFace({ assetId: assetExtraPerson.id, personId: personA.id });
|
||||
await utils.createFace({ assetId: assetExtraPerson.id, personId: personB.id });
|
||||
await utils.createFace({ assetId: assetExtraPerson.id, personId: personC.id });
|
||||
|
||||
await (async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/search/metadata')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({
|
||||
personIds: [],
|
||||
searchOnlyThem: false,
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
numberOfAssets = body.assets.total;
|
||||
})();
|
||||
});
|
||||
|
||||
it('searchOnlyThem is TRUE', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/search/metadata')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({
|
||||
personIds: [personA.id, personB.id],
|
||||
searchOnlyThem: true,
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.assets.items).toHaveLength(1);
|
||||
expect(body.assets.items[0].id).toBe(assetStrictMatch.id);
|
||||
});
|
||||
|
||||
it('searchOnlyThem is TRUE with 4 people', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/search/metadata')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({
|
||||
personIds: [personA.id, personB.id, personC.id, personD.id],
|
||||
searchOnlyThem: true,
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.assets.items).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('searchOnlyThem is TRUE with one person', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/search/metadata')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({
|
||||
personIds: [personA.id],
|
||||
searchOnlyThem: true,
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.assets.items).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('searchOnlyThem is TRUE with no personIds', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/search/metadata')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({
|
||||
personIds: [],
|
||||
searchOnlyThem: true,
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.assets.items).toHaveLength(numberOfAssets);
|
||||
});
|
||||
|
||||
it('searchOnlyThem is FALSE', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/search/metadata')
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({
|
||||
personIds: [personA.id, personB.id],
|
||||
searchOnlyThem: false,
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
const assetIds = body.assets.items.map((a: any) => a.id);
|
||||
expect(assetIds).toContain(assetStrictMatch.id);
|
||||
expect(assetIds).toContain(assetExtraPerson.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1735,6 +1735,7 @@
|
|||
"search_no_people": "No people",
|
||||
"search_no_people_named": "No people named \"{name}\"",
|
||||
"search_no_result": "No results found, try a different search term or combination",
|
||||
"search_only_them": "Only them",
|
||||
"search_options": "Search options",
|
||||
"search_page_categories": "Categories",
|
||||
"search_page_motion_photos": "Motion Photos",
|
||||
|
|
|
|||
13
mobile/openapi/lib/api/search_api.dart
generated
13
mobile/openapi/lib/api/search_api.dart
generated
|
|
@ -350,6 +350,8 @@ class SearchApi {
|
|||
///
|
||||
/// * [num] rating:
|
||||
///
|
||||
/// * [bool] searchOnlyThem:
|
||||
///
|
||||
/// * [num] size:
|
||||
///
|
||||
/// * [String] state:
|
||||
|
|
@ -375,7 +377,7 @@ class SearchApi {
|
|||
/// * [bool] withDeleted:
|
||||
///
|
||||
/// * [bool] withExif:
|
||||
Future<Response> searchLargeAssetsWithHttpInfo({ List<String>? albumIds, String? city, String? country, DateTime? createdAfter, DateTime? createdBefore, String? deviceId, bool? isEncoded, bool? isFavorite, bool? isMotion, bool? isNotInAlbum, bool? isOffline, String? lensModel, String? libraryId, String? make, int? minFileSize, String? model, List<String>? personIds, num? rating, num? size, String? state, List<String>? tagIds, DateTime? takenAfter, DateTime? takenBefore, DateTime? trashedAfter, DateTime? trashedBefore, AssetTypeEnum? type, DateTime? updatedAfter, DateTime? updatedBefore, AssetVisibility? visibility, bool? withDeleted, bool? withExif, }) async {
|
||||
Future<Response> searchLargeAssetsWithHttpInfo({ List<String>? albumIds, String? city, String? country, DateTime? createdAfter, DateTime? createdBefore, String? deviceId, bool? isEncoded, bool? isFavorite, bool? isMotion, bool? isNotInAlbum, bool? isOffline, String? lensModel, String? libraryId, String? make, int? minFileSize, String? model, List<String>? personIds, num? rating, bool? searchOnlyThem, num? size, String? state, List<String>? tagIds, DateTime? takenAfter, DateTime? takenBefore, DateTime? trashedAfter, DateTime? trashedBefore, AssetTypeEnum? type, DateTime? updatedAfter, DateTime? updatedBefore, AssetVisibility? visibility, bool? withDeleted, bool? withExif, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/search/large-assets';
|
||||
|
||||
|
|
@ -440,6 +442,9 @@ class SearchApi {
|
|||
if (rating != null) {
|
||||
queryParams.addAll(_queryParams('', 'rating', rating));
|
||||
}
|
||||
if (searchOnlyThem != null) {
|
||||
queryParams.addAll(_queryParams('', 'searchOnlyThem', searchOnlyThem));
|
||||
}
|
||||
if (size != null) {
|
||||
queryParams.addAll(_queryParams('', 'size', size));
|
||||
}
|
||||
|
|
@ -534,6 +539,8 @@ class SearchApi {
|
|||
///
|
||||
/// * [num] rating:
|
||||
///
|
||||
/// * [bool] searchOnlyThem:
|
||||
///
|
||||
/// * [num] size:
|
||||
///
|
||||
/// * [String] state:
|
||||
|
|
@ -559,8 +566,8 @@ class SearchApi {
|
|||
/// * [bool] withDeleted:
|
||||
///
|
||||
/// * [bool] withExif:
|
||||
Future<List<AssetResponseDto>?> searchLargeAssets({ List<String>? albumIds, String? city, String? country, DateTime? createdAfter, DateTime? createdBefore, String? deviceId, bool? isEncoded, bool? isFavorite, bool? isMotion, bool? isNotInAlbum, bool? isOffline, String? lensModel, String? libraryId, String? make, int? minFileSize, String? model, List<String>? personIds, num? rating, num? size, String? state, List<String>? tagIds, DateTime? takenAfter, DateTime? takenBefore, DateTime? trashedAfter, DateTime? trashedBefore, AssetTypeEnum? type, DateTime? updatedAfter, DateTime? updatedBefore, AssetVisibility? visibility, bool? withDeleted, bool? withExif, }) async {
|
||||
final response = await searchLargeAssetsWithHttpInfo( albumIds: albumIds, city: city, country: country, createdAfter: createdAfter, createdBefore: createdBefore, deviceId: deviceId, isEncoded: isEncoded, isFavorite: isFavorite, isMotion: isMotion, isNotInAlbum: isNotInAlbum, isOffline: isOffline, lensModel: lensModel, libraryId: libraryId, make: make, minFileSize: minFileSize, model: model, personIds: personIds, rating: rating, size: size, state: state, tagIds: tagIds, takenAfter: takenAfter, takenBefore: takenBefore, trashedAfter: trashedAfter, trashedBefore: trashedBefore, type: type, updatedAfter: updatedAfter, updatedBefore: updatedBefore, visibility: visibility, withDeleted: withDeleted, withExif: withExif, );
|
||||
Future<List<AssetResponseDto>?> searchLargeAssets({ List<String>? albumIds, String? city, String? country, DateTime? createdAfter, DateTime? createdBefore, String? deviceId, bool? isEncoded, bool? isFavorite, bool? isMotion, bool? isNotInAlbum, bool? isOffline, String? lensModel, String? libraryId, String? make, int? minFileSize, String? model, List<String>? personIds, num? rating, bool? searchOnlyThem, num? size, String? state, List<String>? tagIds, DateTime? takenAfter, DateTime? takenBefore, DateTime? trashedAfter, DateTime? trashedBefore, AssetTypeEnum? type, DateTime? updatedAfter, DateTime? updatedBefore, AssetVisibility? visibility, bool? withDeleted, bool? withExif, }) async {
|
||||
final response = await searchLargeAssetsWithHttpInfo( albumIds: albumIds, city: city, country: country, createdAfter: createdAfter, createdBefore: createdBefore, deviceId: deviceId, isEncoded: isEncoded, isFavorite: isFavorite, isMotion: isMotion, isNotInAlbum: isNotInAlbum, isOffline: isOffline, lensModel: lensModel, libraryId: libraryId, make: make, minFileSize: minFileSize, model: model, personIds: personIds, rating: rating, searchOnlyThem: searchOnlyThem, size: size, state: state, tagIds: tagIds, takenAfter: takenAfter, takenBefore: takenBefore, trashedAfter: trashedAfter, trashedBefore: trashedBefore, type: type, updatedAfter: updatedAfter, updatedBefore: updatedBefore, visibility: visibility, withDeleted: withDeleted, withExif: withExif, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
|
|
|
|||
19
mobile/openapi/lib/model/metadata_search_dto.dart
generated
19
mobile/openapi/lib/model/metadata_search_dto.dart
generated
|
|
@ -40,6 +40,7 @@ class MetadataSearchDto {
|
|||
this.personIds = const [],
|
||||
this.previewPath,
|
||||
this.rating,
|
||||
this.searchOnlyThem,
|
||||
this.size,
|
||||
this.state,
|
||||
this.tagIds = const [],
|
||||
|
|
@ -229,6 +230,14 @@ class MetadataSearchDto {
|
|||
///
|
||||
num? rating;
|
||||
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
bool? searchOnlyThem;
|
||||
|
||||
/// Minimum value: 1
|
||||
/// Maximum value: 1000
|
||||
///
|
||||
|
|
@ -376,6 +385,7 @@ class MetadataSearchDto {
|
|||
_deepEquality.equals(other.personIds, personIds) &&
|
||||
other.previewPath == previewPath &&
|
||||
other.rating == rating &&
|
||||
other.searchOnlyThem == searchOnlyThem &&
|
||||
other.size == size &&
|
||||
other.state == state &&
|
||||
_deepEquality.equals(other.tagIds, tagIds) &&
|
||||
|
|
@ -423,6 +433,7 @@ class MetadataSearchDto {
|
|||
(personIds.hashCode) +
|
||||
(previewPath == null ? 0 : previewPath!.hashCode) +
|
||||
(rating == null ? 0 : rating!.hashCode) +
|
||||
(searchOnlyThem == null ? 0 : searchOnlyThem!.hashCode) +
|
||||
(size == null ? 0 : size!.hashCode) +
|
||||
(state == null ? 0 : state!.hashCode) +
|
||||
(tagIds == null ? 0 : tagIds!.hashCode) +
|
||||
|
|
@ -441,7 +452,7 @@ class MetadataSearchDto {
|
|||
(withStacked == null ? 0 : withStacked!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'MetadataSearchDto[albumIds=$albumIds, checksum=$checksum, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, description=$description, deviceAssetId=$deviceAssetId, deviceId=$deviceId, encodedVideoPath=$encodedVideoPath, id=$id, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, order=$order, originalFileName=$originalFileName, originalPath=$originalPath, page=$page, personIds=$personIds, previewPath=$previewPath, rating=$rating, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, thumbnailPath=$thumbnailPath, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, visibility=$visibility, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]';
|
||||
String toString() => 'MetadataSearchDto[albumIds=$albumIds, checksum=$checksum, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, description=$description, deviceAssetId=$deviceAssetId, deviceId=$deviceId, encodedVideoPath=$encodedVideoPath, id=$id, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, order=$order, originalFileName=$originalFileName, originalPath=$originalPath, page=$page, personIds=$personIds, previewPath=$previewPath, rating=$rating, searchOnlyThem=$searchOnlyThem, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, thumbnailPath=$thumbnailPath, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, visibility=$visibility, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
|
|
@ -568,6 +579,11 @@ class MetadataSearchDto {
|
|||
} else {
|
||||
// json[r'rating'] = null;
|
||||
}
|
||||
if (this.searchOnlyThem != null) {
|
||||
json[r'searchOnlyThem'] = this.searchOnlyThem;
|
||||
} else {
|
||||
// json[r'searchOnlyThem'] = null;
|
||||
}
|
||||
if (this.size != null) {
|
||||
json[r'size'] = this.size;
|
||||
} else {
|
||||
|
|
@ -691,6 +707,7 @@ class MetadataSearchDto {
|
|||
: const [],
|
||||
previewPath: mapValueOfType<String>(json, r'previewPath'),
|
||||
rating: num.parse('${json[r'rating']}'),
|
||||
searchOnlyThem: mapValueOfType<bool>(json, r'searchOnlyThem'),
|
||||
size: num.parse('${json[r'size']}'),
|
||||
state: mapValueOfType<String>(json, r'state'),
|
||||
tagIds: json[r'tagIds'] is Iterable
|
||||
|
|
|
|||
19
mobile/openapi/lib/model/random_search_dto.dart
generated
19
mobile/openapi/lib/model/random_search_dto.dart
generated
|
|
@ -30,6 +30,7 @@ class RandomSearchDto {
|
|||
this.model,
|
||||
this.personIds = const [],
|
||||
this.rating,
|
||||
this.searchOnlyThem,
|
||||
this.size,
|
||||
this.state,
|
||||
this.tagIds = const [],
|
||||
|
|
@ -143,6 +144,14 @@ class RandomSearchDto {
|
|||
///
|
||||
num? rating;
|
||||
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
bool? searchOnlyThem;
|
||||
|
||||
/// Minimum value: 1
|
||||
/// Maximum value: 1000
|
||||
///
|
||||
|
|
@ -272,6 +281,7 @@ class RandomSearchDto {
|
|||
other.model == model &&
|
||||
_deepEquality.equals(other.personIds, personIds) &&
|
||||
other.rating == rating &&
|
||||
other.searchOnlyThem == searchOnlyThem &&
|
||||
other.size == size &&
|
||||
other.state == state &&
|
||||
_deepEquality.equals(other.tagIds, tagIds) &&
|
||||
|
|
@ -308,6 +318,7 @@ class RandomSearchDto {
|
|||
(model == null ? 0 : model!.hashCode) +
|
||||
(personIds.hashCode) +
|
||||
(rating == null ? 0 : rating!.hashCode) +
|
||||
(searchOnlyThem == null ? 0 : searchOnlyThem!.hashCode) +
|
||||
(size == null ? 0 : size!.hashCode) +
|
||||
(state == null ? 0 : state!.hashCode) +
|
||||
(tagIds == null ? 0 : tagIds!.hashCode) +
|
||||
|
|
@ -325,7 +336,7 @@ class RandomSearchDto {
|
|||
(withStacked == null ? 0 : withStacked!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'RandomSearchDto[albumIds=$albumIds, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, personIds=$personIds, rating=$rating, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, visibility=$visibility, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]';
|
||||
String toString() => 'RandomSearchDto[albumIds=$albumIds, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, personIds=$personIds, rating=$rating, searchOnlyThem=$searchOnlyThem, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, visibility=$visibility, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
|
|
@ -406,6 +417,11 @@ class RandomSearchDto {
|
|||
} else {
|
||||
// json[r'rating'] = null;
|
||||
}
|
||||
if (this.searchOnlyThem != null) {
|
||||
json[r'searchOnlyThem'] = this.searchOnlyThem;
|
||||
} else {
|
||||
// json[r'searchOnlyThem'] = null;
|
||||
}
|
||||
if (this.size != null) {
|
||||
json[r'size'] = this.size;
|
||||
} else {
|
||||
|
|
@ -514,6 +530,7 @@ class RandomSearchDto {
|
|||
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
|
||||
: const [],
|
||||
rating: num.parse('${json[r'rating']}'),
|
||||
searchOnlyThem: mapValueOfType<bool>(json, r'searchOnlyThem'),
|
||||
size: num.parse('${json[r'size']}'),
|
||||
state: mapValueOfType<String>(json, r'state'),
|
||||
tagIds: json[r'tagIds'] is Iterable
|
||||
|
|
|
|||
19
mobile/openapi/lib/model/smart_search_dto.dart
generated
19
mobile/openapi/lib/model/smart_search_dto.dart
generated
|
|
@ -34,6 +34,7 @@ class SmartSearchDto {
|
|||
this.query,
|
||||
this.queryAssetId,
|
||||
this.rating,
|
||||
this.searchOnlyThem,
|
||||
this.size,
|
||||
this.state,
|
||||
this.tagIds = const [],
|
||||
|
|
@ -178,6 +179,14 @@ class SmartSearchDto {
|
|||
///
|
||||
num? rating;
|
||||
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
bool? searchOnlyThem;
|
||||
|
||||
/// Minimum value: 1
|
||||
/// Maximum value: 1000
|
||||
///
|
||||
|
|
@ -295,6 +304,7 @@ class SmartSearchDto {
|
|||
other.query == query &&
|
||||
other.queryAssetId == queryAssetId &&
|
||||
other.rating == rating &&
|
||||
other.searchOnlyThem == searchOnlyThem &&
|
||||
other.size == size &&
|
||||
other.state == state &&
|
||||
_deepEquality.equals(other.tagIds, tagIds) &&
|
||||
|
|
@ -333,6 +343,7 @@ class SmartSearchDto {
|
|||
(query == null ? 0 : query!.hashCode) +
|
||||
(queryAssetId == null ? 0 : queryAssetId!.hashCode) +
|
||||
(rating == null ? 0 : rating!.hashCode) +
|
||||
(searchOnlyThem == null ? 0 : searchOnlyThem!.hashCode) +
|
||||
(size == null ? 0 : size!.hashCode) +
|
||||
(state == null ? 0 : state!.hashCode) +
|
||||
(tagIds == null ? 0 : tagIds!.hashCode) +
|
||||
|
|
@ -348,7 +359,7 @@ class SmartSearchDto {
|
|||
(withExif == null ? 0 : withExif!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SmartSearchDto[albumIds=$albumIds, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, language=$language, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, page=$page, personIds=$personIds, query=$query, queryAssetId=$queryAssetId, rating=$rating, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, visibility=$visibility, withDeleted=$withDeleted, withExif=$withExif]';
|
||||
String toString() => 'SmartSearchDto[albumIds=$albumIds, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, language=$language, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, page=$page, personIds=$personIds, query=$query, queryAssetId=$queryAssetId, rating=$rating, searchOnlyThem=$searchOnlyThem, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, visibility=$visibility, withDeleted=$withDeleted, withExif=$withExif]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
|
|
@ -449,6 +460,11 @@ class SmartSearchDto {
|
|||
} else {
|
||||
// json[r'rating'] = null;
|
||||
}
|
||||
if (this.searchOnlyThem != null) {
|
||||
json[r'searchOnlyThem'] = this.searchOnlyThem;
|
||||
} else {
|
||||
// json[r'searchOnlyThem'] = null;
|
||||
}
|
||||
if (this.size != null) {
|
||||
json[r'size'] = this.size;
|
||||
} else {
|
||||
|
|
@ -551,6 +567,7 @@ class SmartSearchDto {
|
|||
query: mapValueOfType<String>(json, r'query'),
|
||||
queryAssetId: mapValueOfType<String>(json, r'queryAssetId'),
|
||||
rating: num.parse('${json[r'rating']}'),
|
||||
searchOnlyThem: mapValueOfType<bool>(json, r'searchOnlyThem'),
|
||||
size: num.parse('${json[r'size']}'),
|
||||
state: mapValueOfType<String>(json, r'state'),
|
||||
tagIds: json[r'tagIds'] is Iterable
|
||||
|
|
|
|||
19
mobile/openapi/lib/model/statistics_search_dto.dart
generated
19
mobile/openapi/lib/model/statistics_search_dto.dart
generated
|
|
@ -31,6 +31,7 @@ class StatisticsSearchDto {
|
|||
this.model,
|
||||
this.personIds = const [],
|
||||
this.rating,
|
||||
this.searchOnlyThem,
|
||||
this.state,
|
||||
this.tagIds = const [],
|
||||
this.takenAfter,
|
||||
|
|
@ -147,6 +148,14 @@ class StatisticsSearchDto {
|
|||
///
|
||||
num? rating;
|
||||
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
bool? searchOnlyThem;
|
||||
|
||||
String? state;
|
||||
|
||||
List<String>? tagIds;
|
||||
|
|
@ -235,6 +244,7 @@ class StatisticsSearchDto {
|
|||
other.model == model &&
|
||||
_deepEquality.equals(other.personIds, personIds) &&
|
||||
other.rating == rating &&
|
||||
other.searchOnlyThem == searchOnlyThem &&
|
||||
other.state == state &&
|
||||
_deepEquality.equals(other.tagIds, tagIds) &&
|
||||
other.takenAfter == takenAfter &&
|
||||
|
|
@ -267,6 +277,7 @@ class StatisticsSearchDto {
|
|||
(model == null ? 0 : model!.hashCode) +
|
||||
(personIds.hashCode) +
|
||||
(rating == null ? 0 : rating!.hashCode) +
|
||||
(searchOnlyThem == null ? 0 : searchOnlyThem!.hashCode) +
|
||||
(state == null ? 0 : state!.hashCode) +
|
||||
(tagIds == null ? 0 : tagIds!.hashCode) +
|
||||
(takenAfter == null ? 0 : takenAfter!.hashCode) +
|
||||
|
|
@ -279,7 +290,7 @@ class StatisticsSearchDto {
|
|||
(visibility == null ? 0 : visibility!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'StatisticsSearchDto[albumIds=$albumIds, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, description=$description, deviceId=$deviceId, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, personIds=$personIds, rating=$rating, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, visibility=$visibility]';
|
||||
String toString() => 'StatisticsSearchDto[albumIds=$albumIds, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, description=$description, deviceId=$deviceId, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, personIds=$personIds, rating=$rating, searchOnlyThem=$searchOnlyThem, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, visibility=$visibility]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
|
|
@ -365,6 +376,11 @@ class StatisticsSearchDto {
|
|||
} else {
|
||||
// json[r'rating'] = null;
|
||||
}
|
||||
if (this.searchOnlyThem != null) {
|
||||
json[r'searchOnlyThem'] = this.searchOnlyThem;
|
||||
} else {
|
||||
// json[r'searchOnlyThem'] = null;
|
||||
}
|
||||
if (this.state != null) {
|
||||
json[r'state'] = this.state;
|
||||
} else {
|
||||
|
|
@ -449,6 +465,7 @@ class StatisticsSearchDto {
|
|||
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
|
||||
: const [],
|
||||
rating: num.parse('${json[r'rating']}'),
|
||||
searchOnlyThem: mapValueOfType<bool>(json, r'searchOnlyThem'),
|
||||
state: mapValueOfType<String>(json, r'state'),
|
||||
tagIds: json[r'tagIds'] is Iterable
|
||||
? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false)
|
||||
|
|
|
|||
|
|
@ -5958,6 +5958,14 @@
|
|||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "searchOnlyThem",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "size",
|
||||
"required": false,
|
||||
|
|
@ -12651,6 +12659,9 @@
|
|||
"minimum": -1,
|
||||
"type": "number"
|
||||
},
|
||||
"searchOnlyThem": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"size": {
|
||||
"maximum": 1000,
|
||||
"minimum": 1,
|
||||
|
|
@ -13610,6 +13621,9 @@
|
|||
"minimum": -1,
|
||||
"type": "number"
|
||||
},
|
||||
"searchOnlyThem": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"size": {
|
||||
"maximum": 1000,
|
||||
"minimum": 1,
|
||||
|
|
@ -14713,6 +14727,9 @@
|
|||
"minimum": -1,
|
||||
"type": "number"
|
||||
},
|
||||
"searchOnlyThem": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"size": {
|
||||
"maximum": 1000,
|
||||
"minimum": 1,
|
||||
|
|
@ -14907,6 +14924,9 @@
|
|||
"minimum": -1,
|
||||
"type": "number"
|
||||
},
|
||||
"searchOnlyThem": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"state": {
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
|
|
|
|||
|
|
@ -921,6 +921,7 @@ export type MetadataSearchDto = {
|
|||
personIds?: string[];
|
||||
previewPath?: string;
|
||||
rating?: number;
|
||||
searchOnlyThem?: boolean;
|
||||
size?: number;
|
||||
state?: string | null;
|
||||
tagIds?: string[] | null;
|
||||
|
|
@ -988,6 +989,7 @@ export type RandomSearchDto = {
|
|||
model?: string | null;
|
||||
personIds?: string[];
|
||||
rating?: number;
|
||||
searchOnlyThem?: boolean;
|
||||
size?: number;
|
||||
state?: string | null;
|
||||
tagIds?: string[] | null;
|
||||
|
|
@ -1026,6 +1028,7 @@ export type SmartSearchDto = {
|
|||
query?: string;
|
||||
queryAssetId?: string;
|
||||
rating?: number;
|
||||
searchOnlyThem?: boolean;
|
||||
size?: number;
|
||||
state?: string | null;
|
||||
tagIds?: string[] | null;
|
||||
|
|
@ -1059,6 +1062,7 @@ export type StatisticsSearchDto = {
|
|||
model?: string | null;
|
||||
personIds?: string[];
|
||||
rating?: number;
|
||||
searchOnlyThem?: boolean;
|
||||
state?: string | null;
|
||||
tagIds?: string[] | null;
|
||||
takenAfter?: string;
|
||||
|
|
@ -3383,7 +3387,7 @@ export function getExploreData(opts?: Oazapfts.RequestOpts) {
|
|||
/**
|
||||
* This endpoint requires the `asset.read` permission.
|
||||
*/
|
||||
export function searchLargeAssets({ albumIds, city, country, createdAfter, createdBefore, deviceId, isEncoded, isFavorite, isMotion, isNotInAlbum, isOffline, lensModel, libraryId, make, minFileSize, model, personIds, rating, size, state, tagIds, takenAfter, takenBefore, trashedAfter, trashedBefore, $type, updatedAfter, updatedBefore, visibility, withDeleted, withExif }: {
|
||||
export function searchLargeAssets({ albumIds, city, country, createdAfter, createdBefore, deviceId, isEncoded, isFavorite, isMotion, isNotInAlbum, isOffline, lensModel, libraryId, make, minFileSize, model, personIds, rating, searchOnlyThem, size, state, tagIds, takenAfter, takenBefore, trashedAfter, trashedBefore, $type, updatedAfter, updatedBefore, visibility, withDeleted, withExif }: {
|
||||
albumIds?: string[];
|
||||
city?: string | null;
|
||||
country?: string | null;
|
||||
|
|
@ -3402,6 +3406,7 @@ export function searchLargeAssets({ albumIds, city, country, createdAfter, creat
|
|||
model?: string | null;
|
||||
personIds?: string[];
|
||||
rating?: number;
|
||||
searchOnlyThem?: boolean;
|
||||
size?: number;
|
||||
state?: string | null;
|
||||
tagIds?: string[] | null;
|
||||
|
|
@ -3438,6 +3443,7 @@ export function searchLargeAssets({ albumIds, city, country, createdAfter, creat
|
|||
model,
|
||||
personIds,
|
||||
rating,
|
||||
searchOnlyThem,
|
||||
size,
|
||||
state,
|
||||
tagIds,
|
||||
|
|
|
|||
|
|
@ -90,6 +90,9 @@ class BaseSearchDto {
|
|||
@ValidateUUID({ each: true, optional: true })
|
||||
personIds?: string[];
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
searchOnlyThem?: boolean;
|
||||
|
||||
@ValidateUUID({ each: true, optional: true, nullable: true })
|
||||
tagIds?: string[] | null;
|
||||
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ export interface SearchEmbeddingOptions {
|
|||
|
||||
export interface SearchPeopleOptions {
|
||||
personIds?: string[];
|
||||
searchOnlyThem?: boolean;
|
||||
}
|
||||
|
||||
export interface SearchTagOptions {
|
||||
|
|
|
|||
|
|
@ -216,17 +216,39 @@ export function withFacesAndPeople(eb: ExpressionBuilder<DB, 'asset'>, withDelet
|
|||
).as('faces');
|
||||
}
|
||||
|
||||
export function hasPeople<O>(qb: SelectQueryBuilder<DB, 'asset', O>, personIds: string[]) {
|
||||
export function hasPeople<O>(
|
||||
qb: SelectQueryBuilder<DB, 'asset', O>,
|
||||
personIds: string[],
|
||||
searchOnlyThem: boolean = false,
|
||||
) {
|
||||
if (!searchOnlyThem) {
|
||||
return qb.innerJoin(
|
||||
(eb) =>
|
||||
eb
|
||||
.selectFrom('asset_face')
|
||||
.select('assetId')
|
||||
.where('personId', 'in', personIds)
|
||||
.where('deletedAt', 'is', null)
|
||||
.groupBy('assetId')
|
||||
.having((eb) => eb.fn.count('personId').distinct(), '>=', personIds.length)
|
||||
.as('has_people'),
|
||||
(join) => join.onRef('has_people.assetId', '=', 'asset.id'),
|
||||
);
|
||||
}
|
||||
|
||||
return qb.innerJoin(
|
||||
(eb) =>
|
||||
eb
|
||||
(eb) => {
|
||||
const sortedPersonIds = [...personIds].sort();
|
||||
const arrayLiteral = `ARRAY[${sortedPersonIds.map((id) => `'${id}'::uuid`).join(', ')}]`;
|
||||
|
||||
return eb
|
||||
.selectFrom('asset_face')
|
||||
.select('assetId')
|
||||
.where('personId', '=', anyUuid(personIds!))
|
||||
.where('deletedAt', 'is', null)
|
||||
.groupBy('assetId')
|
||||
.having((eb) => eb.fn.count('personId').distinct(), '=', personIds.length)
|
||||
.as('has_people'),
|
||||
.having(() => sql`ARRAY_AGG(DISTINCT "personId" ORDER BY "personId") = ${sql.raw(arrayLiteral)}`)
|
||||
.as('has_people');
|
||||
},
|
||||
(join) => join.onRef('has_people.assetId', '=', 'asset.id'),
|
||||
);
|
||||
}
|
||||
|
|
@ -314,7 +336,9 @@ export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuild
|
|||
.$if(options.tagIds === null, (qb) =>
|
||||
qb.where((eb) => eb.not(eb.exists((eb) => eb.selectFrom('tag_asset').whereRef('assetsId', '=', 'asset.id')))),
|
||||
)
|
||||
.$if(!!options.personIds && options.personIds.length > 0, (qb) => hasPeople(qb, options.personIds!))
|
||||
.$if(!!options.personIds && options.personIds.length > 0, (qb) =>
|
||||
hasPeople(qb, options.personIds!, options.searchOnlyThem),
|
||||
)
|
||||
.$if(!!options.createdBefore, (qb) => qb.where('asset.createdAt', '<=', options.createdBefore!))
|
||||
.$if(!!options.createdAfter, (qb) => qb.where('asset.createdAt', '>=', options.createdAfter!))
|
||||
.$if(!!options.updatedBefore, (qb) => qb.where('asset.updatedAt', '<=', options.updatedBefore!))
|
||||
|
|
|
|||
|
|
@ -5,16 +5,17 @@
|
|||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getAllPeople, type PersonResponseDto } from '@immich/sdk';
|
||||
import { Button, LoadingSpinner } from '@immich/ui';
|
||||
import { Button, Checkbox, Label, LoadingSpinner } from '@immich/ui';
|
||||
import { mdiArrowRight, mdiClose } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
interface Props {
|
||||
selectedPeople: SvelteSet<string>;
|
||||
searchOnlyThem: boolean;
|
||||
}
|
||||
|
||||
let { selectedPeople = $bindable() }: Props = $props();
|
||||
let { selectedPeople = $bindable(), searchOnlyThem = $bindable() }: Props = $props();
|
||||
|
||||
let peoplePromise = getPeople();
|
||||
let showAllPeople = $state(false);
|
||||
|
|
@ -61,12 +62,19 @@
|
|||
? filterPeople(people, name)
|
||||
: filterPeople(people, name).slice(0, numberOfPeople)}
|
||||
|
||||
<div id="people-selection" class="max-h-60 -mb-4 overflow-y-auto immich-scrollbar">
|
||||
<div id="people-selection" class="max-h-66 -mb-4 overflow-y-auto immich-scrollbar">
|
||||
<div class="flex items-center w-full justify-between gap-6">
|
||||
<p class="uppercase immich-form-label py-3">{$t('people')}</p>
|
||||
<SearchBar bind:name placeholder={$t('filter_people')} showLoadingSpinner={false} />
|
||||
</div>
|
||||
|
||||
{#if selectedPeople.size > 0}
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox id="search-only-them-checkbox" size="tiny" bind:checked={searchOnlyThem} />
|
||||
<Label for="search-only-them-checkbox">{$t('search_only_them')}</Label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<SingleGridRow
|
||||
class="grid grid-auto-fill-20 gap-1 mt-2 overflow-y-auto immich-scrollbar"
|
||||
bind:itemCount={numberOfPeople}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
query: string;
|
||||
queryType: 'smart' | 'metadata' | 'description';
|
||||
personIds: SvelteSet<string>;
|
||||
searchOnlyThem: boolean;
|
||||
tagIds: SvelteSet<string> | null;
|
||||
location: SearchLocationFilter;
|
||||
camera: SearchCameraFilter;
|
||||
|
|
@ -76,6 +77,10 @@
|
|||
query,
|
||||
queryType: defaultQueryType(),
|
||||
personIds: new SvelteSet('personIds' in searchQuery ? searchQuery.personIds : []),
|
||||
searchOnlyThem:
|
||||
'searchOnlyThem' in searchQuery && typeof searchQuery.searchOnlyThem === 'boolean'
|
||||
? searchQuery.searchOnlyThem
|
||||
: false,
|
||||
tagIds:
|
||||
'tagIds' in searchQuery
|
||||
? searchQuery.tagIds === null
|
||||
|
|
@ -114,6 +119,7 @@
|
|||
query: '',
|
||||
queryType: defaultQueryType(), // retain from localStorage or default
|
||||
personIds: new SvelteSet(),
|
||||
searchOnlyThem: false,
|
||||
tagIds: new SvelteSet(),
|
||||
location: {},
|
||||
camera: {},
|
||||
|
|
@ -153,6 +159,7 @@
|
|||
isFavorite: filter.display.isFavorite || undefined,
|
||||
isNotInAlbum: filter.display.isNotInAlbum || undefined,
|
||||
personIds: filter.personIds.size > 0 ? [...filter.personIds] : undefined,
|
||||
searchOnlyThem: filter.searchOnlyThem === true ? true : undefined,
|
||||
tagIds: filter.tagIds === null ? null : filter.tagIds.size > 0 ? [...filter.tagIds] : undefined,
|
||||
type,
|
||||
rating: filter.rating,
|
||||
|
|
@ -183,7 +190,7 @@
|
|||
<form id={formId} autocomplete="off" {onsubmit} {onreset}>
|
||||
<div class="flex flex-col gap-4 pb-10" tabindex="-1">
|
||||
<!-- PEOPLE -->
|
||||
<SearchPeopleSection bind:selectedPeople={filter.personIds} />
|
||||
<SearchPeopleSection bind:selectedPeople={filter.personIds} bind:searchOnlyThem={filter.searchOnlyThem} />
|
||||
|
||||
<!-- TEXT -->
|
||||
<SearchTextSection bind:query={filter.query} bind:queryType={filter.queryType} />
|
||||
|
|
|
|||
|
|
@ -201,6 +201,7 @@
|
|||
model: $t('camera_model'),
|
||||
lensModel: $t('lens_model'),
|
||||
personIds: $t('people'),
|
||||
searchOnlyThem: $t('search_only_them'),
|
||||
tagIds: $t('tags'),
|
||||
originalFileName: $t('file_name'),
|
||||
description: $t('description'),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue