From 92b1c43c00907d5b1300bd69b07cb144e4a48c09 Mon Sep 17 00:00:00 2001 From: Dag Stuan Date: Wed, 10 Sep 2025 21:21:06 +0200 Subject: [PATCH] Add search filter for camera lens model. Add a search filter next to the camera make to also allow filtering by camera lens model. Update backend to support this. Add a test similar to the other tests. --- i18n/en.json | 1 + mobile/openapi/lib/api/search_api.dart | 13 +++++-- .../lib/model/search_suggestion_type.dart | 3 ++ open-api/immich-openapi-specs.json | 11 +++++- open-api/typescript-sdk/src/fetch-client.ts | 7 +++- server/src/dtos/search.dto.ts | 5 +++ server/src/queries/search.repository.sql | 12 ++++++ server/src/repositories/search.repository.ts | 32 ++++++++++++--- server/src/services/search.service.spec.ts | 20 ++++++++++ server/src/services/search.service.ts | 3 ++ .../search-bar/search-camera-section.svelte | 39 +++++++++++++++++-- web/src/lib/modals/SearchFilterModal.svelte | 2 + 12 files changed, 133 insertions(+), 15 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index f8a51eb5f6..0d3c73dc9e 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1672,6 +1672,7 @@ "search_by_filename_example": "i.e. IMG_1234.JPG or PNG", "search_camera_make": "Search camera make...", "search_camera_model": "Search camera model...", + "search_camera_lens_model": "Search lens model...", "search_city": "Search city...", "search_country": "Search country...", "search_filter_apply": "Apply filter", diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart index 4d9e1172b8..788fc333fa 100644 --- a/mobile/openapi/lib/api/search_api.dart +++ b/mobile/openapi/lib/api/search_api.dart @@ -123,12 +123,14 @@ class SearchApi { /// * [bool] includeNull: /// This property was added in v111.0.0 /// + /// * [String] lensModel: + /// /// * [String] make: /// /// * [String] model: /// /// * [String] state: - Future getSearchSuggestionsWithHttpInfo(SearchSuggestionType type, { String? country, bool? includeNull, String? make, String? model, String? state, }) async { + Future getSearchSuggestionsWithHttpInfo(SearchSuggestionType type, { String? country, bool? includeNull, String? lensModel, String? make, String? model, String? state, }) async { // ignore: prefer_const_declarations final apiPath = r'/search/suggestions'; @@ -145,6 +147,9 @@ class SearchApi { if (includeNull != null) { queryParams.addAll(_queryParams('', 'includeNull', includeNull)); } + if (lensModel != null) { + queryParams.addAll(_queryParams('', 'lensModel', lensModel)); + } if (make != null) { queryParams.addAll(_queryParams('', 'make', make)); } @@ -181,13 +186,15 @@ class SearchApi { /// * [bool] includeNull: /// This property was added in v111.0.0 /// + /// * [String] lensModel: + /// /// * [String] make: /// /// * [String] model: /// /// * [String] state: - Future?> getSearchSuggestions(SearchSuggestionType type, { String? country, bool? includeNull, String? make, String? model, String? state, }) async { - final response = await getSearchSuggestionsWithHttpInfo(type, country: country, includeNull: includeNull, make: make, model: model, state: state, ); + Future?> getSearchSuggestions(SearchSuggestionType type, { String? country, bool? includeNull, String? lensModel, String? make, String? model, String? state, }) async { + final response = await getSearchSuggestionsWithHttpInfo(type, country: country, includeNull: includeNull, lensModel: lensModel, make: make, model: model, state: state, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/model/search_suggestion_type.dart b/mobile/openapi/lib/model/search_suggestion_type.dart index 3f905e029d..b18fe687c4 100644 --- a/mobile/openapi/lib/model/search_suggestion_type.dart +++ b/mobile/openapi/lib/model/search_suggestion_type.dart @@ -28,6 +28,7 @@ class SearchSuggestionType { static const city = SearchSuggestionType._(r'city'); static const cameraMake = SearchSuggestionType._(r'camera-make'); static const cameraModel = SearchSuggestionType._(r'camera-model'); + static const cameraLensModel = SearchSuggestionType._(r'camera-lens-model'); /// List of all possible values in this [enum][SearchSuggestionType]. static const values = [ @@ -36,6 +37,7 @@ class SearchSuggestionType { city, cameraMake, cameraModel, + cameraLensModel, ]; static SearchSuggestionType? fromJson(dynamic value) => SearchSuggestionTypeTypeTransformer().decode(value); @@ -79,6 +81,7 @@ class SearchSuggestionTypeTypeTransformer { case r'city': return SearchSuggestionType.city; case r'camera-make': return SearchSuggestionType.cameraMake; case r'camera-model': return SearchSuggestionType.cameraModel; + case r'camera-lens-model': return SearchSuggestionType.cameraLensModel; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 5a7886253e..35da672e01 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -6407,6 +6407,14 @@ "type": "boolean" } }, + { + "name": "lensModel", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, { "name": "make", "required": false, @@ -13829,7 +13837,8 @@ "state", "city", "camera-make", - "camera-model" + "camera-model", + "camera-lens-model" ], "type": "string" }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index a74afd733d..8b10bf2995 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -3537,9 +3537,10 @@ export function searchAssetStatistics({ statisticsSearchDto }: { /** * This endpoint requires the `asset.read` permission. */ -export function getSearchSuggestions({ country, includeNull, make, model, state, $type }: { +export function getSearchSuggestions({ country, includeNull, lensModel, make, model, state, $type }: { country?: string; includeNull?: boolean; + lensModel?: string; make?: string; model?: string; state?: string; @@ -3551,6 +3552,7 @@ export function getSearchSuggestions({ country, includeNull, make, model, state, }>(`/search/suggestions${QS.query(QS.explode({ country, includeNull, + lensModel, make, model, state, @@ -4883,7 +4885,8 @@ export enum SearchSuggestionType { State = "state", City = "city", CameraMake = "camera-make", - CameraModel = "camera-model" + CameraModel = "camera-model", + CameraLensModel = "camera-lens-model" } export enum SharedLinkType { Album = "ALBUM", diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index ac7bf4feb2..30cdf3602e 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -257,6 +257,7 @@ export enum SearchSuggestionType { CITY = 'city', CAMERA_MAKE = 'camera-make', CAMERA_MODEL = 'camera-model', + CAMERA_LENS_MODEL = 'camera-lens-model', } export class SearchSuggestionRequestDto { @@ -279,6 +280,10 @@ export class SearchSuggestionRequestDto { @Optional() model?: string; + @IsString() + @Optional() + lensModel?: string; + @ValidateBoolean({ optional: true }) @PropertyLifecycle({ addedAt: 'v111.0.0' }) includeNull?: boolean; diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index e0aaedfdf3..ef5fbe09be 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -290,3 +290,15 @@ where and "visibility" = $2 and "deletedAt" is null and "model" is not null + +-- SearchRepository.getCameraLensModels +select distinct + on ("lensModel") "lensModel" +from + "asset_exif" + inner join "asset" on "asset"."id" = "asset_exif"."assetId" +where + "ownerId" = any ($1::uuid[]) + and "visibility" = $2 + and "deletedAt" is null + and "lensModel" is not null diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 88de2fb06f..650c112591 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -160,10 +160,17 @@ export interface GetCitiesOptions extends GetStatesOptions { export interface GetCameraModelsOptions { make?: string; + lensModel?: string; } export interface GetCameraMakesOptions { model?: string; + lensModel?: string; +} + +export interface GetCameraLensModelsOptions { + make?: string; + model?: string; } @Injectable() @@ -457,25 +464,40 @@ export class SearchRepository { return res.map((row) => row.city!); } - @GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] }) - async getCameraMakes(userIds: string[], { model }: GetCameraMakesOptions): Promise { + @GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING, DummyValue.STRING] }) + async getCameraMakes(userIds: string[], { model, lensModel }: GetCameraMakesOptions): Promise { const res = await this.getExifField('make', userIds) .$if(!!model, (qb) => qb.where('model', '=', model!)) + .$if(!!lensModel, (qb) => qb.where('lensModel', '=', lensModel!)) .execute(); return res.map((row) => row.make!); } - @GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] }) - async getCameraModels(userIds: string[], { make }: GetCameraModelsOptions): Promise { + @GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING, DummyValue.STRING] }) + async getCameraModels(userIds: string[], { make, lensModel }: GetCameraModelsOptions): Promise { const res = await this.getExifField('model', userIds) .$if(!!make, (qb) => qb.where('make', '=', make!)) + .$if(!!lensModel, (qb) => qb.where('lensModel', '=', lensModel!)) .execute(); return res.map((row) => row.model!); } - private getExifField(field: K, userIds: string[]) { + @GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] }) + async getCameraLensModels(userIds: string[], { make, model }: GetCameraLensModelsOptions): Promise { + const res = await this.getExifField('lensModel', userIds) + .$if(!!make, (qb) => qb.where('make', '=', make!)) + .$if(!!model, (qb) => qb.where('model', '=', model!)) + .execute(); + + return res.map((row) => row.lensModel!); + } + + private getExifField( + field: K, + userIds: string[], + ) { return this.db .selectFrom('asset_exif') .select(field) diff --git a/server/src/services/search.service.spec.ts b/server/src/services/search.service.spec.ts index d87ccbde1d..ecd9b0f98e 100644 --- a/server/src/services/search.service.spec.ts +++ b/server/src/services/search.service.spec.ts @@ -179,6 +179,26 @@ describe(SearchService.name, () => { ).resolves.toEqual(['Fujifilm X100VI', null]); expect(mocks.search.getCameraModels).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); }); + + it('should return search suggestions for camera lens model', async () => { + mocks.search.getCameraLensModels.mockResolvedValue(['10-24mm']); + mocks.partner.getAll.mockResolvedValue([]); + + await expect( + sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.CAMERA_LENS_MODEL }), + ).resolves.toEqual(['10-24mm']); + expect(mocks.search.getCameraLensModels).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); + }); + + it('should return search suggestions for camera lens model (including null)', async () => { + mocks.search.getCameraLensModels.mockResolvedValue(['10-24mm']); + mocks.partner.getAll.mockResolvedValue([]); + + await expect( + sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.CAMERA_LENS_MODEL }), + ).resolves.toEqual(['10-24mm', null]); + expect(mocks.search.getCameraLensModels).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything()); + }); }); describe('searchSmart', () => { diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index 51a2c94338..978f481804 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -177,6 +177,9 @@ export class SearchService extends BaseService { case SearchSuggestionType.CAMERA_MODEL: { return this.searchRepository.getCameraModels(userIds, dto); } + case SearchSuggestionType.CAMERA_LENS_MODEL: { + return this.searchRepository.getCameraLensModels(userIds, dto); + } default: { return Promise.resolve([]); } diff --git a/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte b/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte index 08ed57d70e..6e5ca743bc 100644 --- a/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte @@ -2,12 +2,11 @@ export interface SearchCameraFilter { make?: string; model?: string; + lensModel?: string; }
@@ -81,5 +102,15 @@ selectedOption={asSelectedOption(modelFilter)} />
+ +
+ (filters.lensModel = option?.value)} + options={asComboboxOptions(lensModels)} + placeholder={$t('search_camera_lens_model')} + selectedOption={asSelectedOption(lensModelFilter)} + /> +
diff --git a/web/src/lib/modals/SearchFilterModal.svelte b/web/src/lib/modals/SearchFilterModal.svelte index b9841c311e..2315d3de32 100644 --- a/web/src/lib/modals/SearchFilterModal.svelte +++ b/web/src/lib/modals/SearchFilterModal.svelte @@ -90,6 +90,7 @@ camera: { make: withNullAsUndefined(searchQuery.make), model: withNullAsUndefined(searchQuery.model), + lensModel: withNullAsUndefined(searchQuery.lensModel), }, date: { takenAfter: searchQuery.takenAfter ? toStartOfDayDate(searchQuery.takenAfter) : undefined, @@ -147,6 +148,7 @@ city: filter.location.city, make: filter.camera.make, model: filter.camera.model, + lensModel: filter.camera.lensModel, takenAfter: parseOptionalDate(filter.date.takenAfter)?.startOf('day').toISO() || undefined, takenBefore: parseOptionalDate(filter.date.takenBefore)?.endOf('day').toISO() || undefined, visibility: filter.display.isArchive ? AssetVisibility.Archive : undefined,