From 78fb815cdba71243f95fb3824ef1b933693e9076 Mon Sep 17 00:00:00 2001 From: Dag Stuan Date: Fri, 24 Oct 2025 20:41:34 +0200 Subject: [PATCH] feat(web): add search filter for camera lens model. (#21792) --- i18n/en.json | 1 + mobile/openapi/lib/api/search_api.dart | Bin 26516 -> 26747 bytes .../lib/model/search_suggestion_type.dart | Bin 3225 -> 3405 bytes 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, 120 insertions(+), 12 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index 02e573de6..8e8913263 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1710,6 +1710,7 @@ "search_by_description_example": "Hiking day in Sapa", "search_by_filename": "Search by file name or extension", "search_by_filename_example": "i.e. IMG_1234.JPG or PNG", + "search_camera_lens_model": "Search lens model...", "search_camera_make": "Search camera make...", "search_camera_model": "Search camera model...", "search_city": "Search city...", diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart index 4d9e1172b8889205c7fbb284c15545e5820e9d0e..788fc333fa980bff2ca50db93a7951c6ed89f188 100644 GIT binary patch delta 100 zcmbPop7Hku#tkw2li9VDCg+QKPhQSnGI@ut@MdWNF{a5kP0}YXH;tT}YZf*6rFr;f tYhe>6sLCB6RgwYi%%{T3lYvq%`5iJPf5+OQh+fx9}>G1003-~C0zgj delta 52 zcmex;fpN-t#tkw2lk52RPyQihJh?yGdGj%W1&ovX%py0d2|r~5%6Lz%5X%M1&fC03 H{Cofaa>^Gu diff --git a/mobile/openapi/lib/model/search_suggestion_type.dart b/mobile/openapi/lib/model/search_suggestion_type.dart index 3f905e029dfb725d1759c412f16e89f7947e6165..b18fe687c443b2bbaca5ffaa65e1e1218e5d6179 100644 GIT binary patch delta 82 zcmbO!c~)ve7V~5kW>FTO)V$)!mdqL~IUq&?^9ddWh2+HC)S^U?%w~C(ME1#++KbeO&TfNJrHB)j!7W3qt%=?&Qt+^%(@XBpo%VN&H*@Q=u5dfP~2<89) diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 3b258d505..17aa12171 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -6458,6 +6458,14 @@ "type": "boolean" } }, + { + "name": "lensModel", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, { "name": "make", "required": false, @@ -13941,7 +13949,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 cdd004770..1ef73f22a 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -3566,9 +3566,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; @@ -3580,6 +3581,7 @@ export function getSearchSuggestions({ country, includeNull, make, model, state, }>(`/search/suggestions${QS.query(QS.explode({ country, includeNull, + lensModel, make, model, state, @@ -4919,7 +4921,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 5f8b018af..2dcf97a57 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -249,6 +249,7 @@ export enum SearchSuggestionType { CITY = 'city', CAMERA_MAKE = 'camera-make', CAMERA_MODEL = 'camera-model', + CAMERA_LENS_MODEL = 'camera-lens-model', } export class SearchSuggestionRequestDto { @@ -271,6 +272,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 e0aaedfdf..ef5fbe09b 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 88de2fb06..650c11259 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 b6e09add1..0dec02f18 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 fea1670e2..9a6f8321a 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 aa5b0ee7e..1d451eacc 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 b9841c311..2315d3de3 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,