diff --git a/i18n/en.json b/i18n/en.json index d265c9b9d8..2f4d3637f8 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1712,6 +1712,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 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 fdfc40eb6c..0330b3b6d5 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -6410,6 +6410,14 @@ "type": "boolean" } }, + { + "name": "lensModel", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, { "name": "make", "required": false, @@ -13892,7 +13900,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 5c952c30af..d31c0c4b5a 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -3551,9 +3551,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; @@ -3565,6 +3566,7 @@ export function getSearchSuggestions({ country, includeNull, make, model, state, }>(`/search/suggestions${QS.query(QS.explode({ country, includeNull, + lensModel, make, model, state, @@ -4903,7 +4905,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 5f8b018afe..2dcf97a574 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 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 b6e09add19..0dec02f18f 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 fea1670e27..9a6f8321a9 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 aa5b0ee7e4..1d451eacc4 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,