feat(web): add search filter for camera lens model. (#21792)
Some checks failed
CLI Build / CLI Publish (push) Has been cancelled
CLI Build / Docker (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docker / pre-job (push) Has been cancelled
Docker / Re-Tag ML () (push) Has been cancelled
Docker / Re-Tag ML (-armnn) (push) Has been cancelled
Docker / Re-Tag ML (-cuda) (push) Has been cancelled
Docker / Re-Tag ML (-openvino) (push) Has been cancelled
Docker / Re-Tag ML (-rknn) (push) Has been cancelled
Docker / Re-Tag ML (-rocm) (push) Has been cancelled
Docker / Re-Tag Server () (push) Has been cancelled
Docker / Build and Push ML (armnn, linux/arm64, -armnn) (push) Has been cancelled
Docker / Build and Push ML (cpu) (push) Has been cancelled
Docker / Build and Push ML (cuda, linux/amd64, -cuda) (push) Has been cancelled
Docker / Build and Push ML (openvino, linux/amd64, -openvino) (push) Has been cancelled
Docker / Build and Push ML (rknn, linux/arm64, -rknn) (push) Has been cancelled
Docker / Build and Push ML (rocm, linux/amd64, {"linux/amd64": "mich"}, -rocm) (push) Has been cancelled
Docker / Build and Push Server (push) Has been cancelled
Docker / Docker Build & Push Server Success (push) Has been cancelled
Docker / Docker Build & Push ML Success (push) Has been cancelled
Docs build / pre-job (push) Has been cancelled
Docs build / Docs Build (push) Has been cancelled
Zizmor / Zizmor (push) Has been cancelled
Static Code Analysis / pre-job (push) Has been cancelled
Static Code Analysis / Run Dart Code Analysis (push) Has been cancelled
Test / pre-job (push) Has been cancelled
Test / Test & Lint Server (push) Has been cancelled
Test / Unit Test CLI (push) Has been cancelled
Test / Unit Test CLI (Windows) (push) Has been cancelled
Test / Lint Web (push) Has been cancelled
Test / Test Web (push) Has been cancelled
Test / Test i18n (push) Has been cancelled
Test / End-to-End Lint (push) Has been cancelled
Test / Medium Tests (Server) (push) Has been cancelled
Test / End-to-End Tests (Server & CLI) (ubuntu-24.04-arm) (push) Has been cancelled
Test / End-to-End Tests (Server & CLI) (ubuntu-latest) (push) Has been cancelled
Test / End-to-End Tests (Web) (ubuntu-24.04-arm) (push) Has been cancelled
Test / End-to-End Tests (Web) (ubuntu-latest) (push) Has been cancelled
Test / End-to-End Tests Success (push) Has been cancelled
Test / Unit Test Mobile (push) Has been cancelled
Test / Unit Test ML (push) Has been cancelled
Test / .github Files Formatting (push) Has been cancelled
Test / ShellCheck (push) Has been cancelled
Test / OpenAPI Clients (push) Has been cancelled
Test / SQL Schema Checks (push) Has been cancelled

This commit is contained in:
Dag Stuan
2025-10-24 20:41:34 +02:00
committed by GitHub
parent d9cddeb0f1
commit 78fb815cdb
12 changed files with 133 additions and 15 deletions

View File

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

View File

@@ -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<Response> getSearchSuggestionsWithHttpInfo(SearchSuggestionType type, { String? country, bool? includeNull, String? make, String? model, String? state, }) async {
Future<Response> 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<List<String>?> 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<List<String>?> 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));
}

View File

@@ -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 = <SearchSuggestionType>[
@@ -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');

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string[]> {
@GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING, DummyValue.STRING] })
async getCameraMakes(userIds: string[], { model, lensModel }: GetCameraMakesOptions): Promise<string[]> {
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<string[]> {
@GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING, DummyValue.STRING] })
async getCameraModels(userIds: string[], { make, lensModel }: GetCameraModelsOptions): Promise<string[]> {
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<K extends 'city' | 'state' | 'country' | 'make' | 'model'>(field: K, userIds: string[]) {
@GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] })
async getCameraLensModels(userIds: string[], { make, model }: GetCameraLensModelsOptions): Promise<string[]> {
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<K extends 'city' | 'state' | 'country' | 'make' | 'model' | 'lensModel'>(
field: K,
userIds: string[],
) {
return this.db
.selectFrom('asset_exif')
.select(field)

View File

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

View File

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

View File

@@ -2,12 +2,11 @@
export interface SearchCameraFilter {
make?: string;
model?: string;
lensModel?: string;
}
</script>
<script lang="ts">
import { run } from 'svelte/legacy';
import Combobox, { asComboboxOptions, asSelectedOption } from '$lib/components/shared-components/combobox.svelte';
import { handlePromiseError } from '$lib/utils';
import { SearchSuggestionType, getSearchSuggestions } from '@immich/sdk';
@@ -21,6 +20,7 @@
let makes: string[] = $state([]);
let models: string[] = $state([]);
let lensModels: string[] = $state([]);
async function updateMakes() {
const results: Array<string | null> = await getSearchSuggestions({
@@ -48,14 +48,35 @@
filters.model = undefined;
}
}
async function updateLensModels(make?: string, model?: string) {
const results: Array<string | null> = await getSearchSuggestions({
$type: SearchSuggestionType.CameraLensModel,
make,
model,
includeNull: true,
});
lensModels = results.map((result) => result ?? '');
if (filters.lensModel && !lensModels.includes(filters.lensModel)) {
filters.lensModel = undefined;
}
}
let makeFilter = $derived(filters.make);
let modelFilter = $derived(filters.model);
run(() => {
let lensModelFilter = $derived(filters.lensModel);
$effect(() => {
handlePromiseError(updateMakes());
});
run(() => {
$effect(() => {
handlePromiseError(updateModels(makeFilter));
});
$effect(() => {
handlePromiseError(updateLensModels(makeFilter, modelFilter));
});
</script>
<div id="camera-selection">
@@ -81,5 +102,15 @@
selectedOption={asSelectedOption(modelFilter)}
/>
</div>
<div class="w-full">
<Combobox
label={$t('lens_model')}
onSelect={(option) => (filters.lensModel = option?.value)}
options={asComboboxOptions(lensModels)}
placeholder={$t('search_camera_lens_model')}
selectedOption={asSelectedOption(lensModelFilter)}
/>
</div>
</div>
</div>

View File

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