This commit is contained in:
nosajthenitram 2025-10-17 12:38:18 -05:00 committed by GitHub
commit 913494da44
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 274 additions and 19 deletions

View file

@ -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);
});
});
});

View file

@ -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",

View file

@ -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));
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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"

View file

@ -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,

View file

@ -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;

View file

@ -86,6 +86,7 @@ export interface SearchEmbeddingOptions {
export interface SearchPeopleOptions {
personIds?: string[];
searchOnlyThem?: boolean;
}
export interface SearchTagOptions {

View file

@ -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!))

View file

@ -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}

View file

@ -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} />

View file

@ -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'),