mirror of
https://github.com/zebrajr/immich.git
synced 2025-12-06 12:20:54 +01:00
feat(web): add search filter for camera lens model. (#21792)
This commit is contained in:
parent
d9cddeb0f1
commit
78fb815cdb
|
|
@ -1710,6 +1710,7 @@
|
||||||
"search_by_description_example": "Hiking day in Sapa",
|
"search_by_description_example": "Hiking day in Sapa",
|
||||||
"search_by_filename": "Search by file name or extension",
|
"search_by_filename": "Search by file name or extension",
|
||||||
"search_by_filename_example": "i.e. IMG_1234.JPG or PNG",
|
"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_make": "Search camera make...",
|
||||||
"search_camera_model": "Search camera model...",
|
"search_camera_model": "Search camera model...",
|
||||||
"search_city": "Search city...",
|
"search_city": "Search city...",
|
||||||
|
|
|
||||||
BIN
mobile/openapi/lib/api/search_api.dart
generated
BIN
mobile/openapi/lib/api/search_api.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/search_suggestion_type.dart
generated
BIN
mobile/openapi/lib/model/search_suggestion_type.dart
generated
Binary file not shown.
|
|
@ -6458,6 +6458,14 @@
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "lensModel",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "make",
|
"name": "make",
|
||||||
"required": false,
|
"required": false,
|
||||||
|
|
@ -13941,7 +13949,8 @@
|
||||||
"state",
|
"state",
|
||||||
"city",
|
"city",
|
||||||
"camera-make",
|
"camera-make",
|
||||||
"camera-model"
|
"camera-model",
|
||||||
|
"camera-lens-model"
|
||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -3566,9 +3566,10 @@ export function searchAssetStatistics({ statisticsSearchDto }: {
|
||||||
/**
|
/**
|
||||||
* This endpoint requires the `asset.read` permission.
|
* 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;
|
country?: string;
|
||||||
includeNull?: boolean;
|
includeNull?: boolean;
|
||||||
|
lensModel?: string;
|
||||||
make?: string;
|
make?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
state?: string;
|
state?: string;
|
||||||
|
|
@ -3580,6 +3581,7 @@ export function getSearchSuggestions({ country, includeNull, make, model, state,
|
||||||
}>(`/search/suggestions${QS.query(QS.explode({
|
}>(`/search/suggestions${QS.query(QS.explode({
|
||||||
country,
|
country,
|
||||||
includeNull,
|
includeNull,
|
||||||
|
lensModel,
|
||||||
make,
|
make,
|
||||||
model,
|
model,
|
||||||
state,
|
state,
|
||||||
|
|
@ -4919,7 +4921,8 @@ export enum SearchSuggestionType {
|
||||||
State = "state",
|
State = "state",
|
||||||
City = "city",
|
City = "city",
|
||||||
CameraMake = "camera-make",
|
CameraMake = "camera-make",
|
||||||
CameraModel = "camera-model"
|
CameraModel = "camera-model",
|
||||||
|
CameraLensModel = "camera-lens-model"
|
||||||
}
|
}
|
||||||
export enum SharedLinkType {
|
export enum SharedLinkType {
|
||||||
Album = "ALBUM",
|
Album = "ALBUM",
|
||||||
|
|
|
||||||
|
|
@ -249,6 +249,7 @@ export enum SearchSuggestionType {
|
||||||
CITY = 'city',
|
CITY = 'city',
|
||||||
CAMERA_MAKE = 'camera-make',
|
CAMERA_MAKE = 'camera-make',
|
||||||
CAMERA_MODEL = 'camera-model',
|
CAMERA_MODEL = 'camera-model',
|
||||||
|
CAMERA_LENS_MODEL = 'camera-lens-model',
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SearchSuggestionRequestDto {
|
export class SearchSuggestionRequestDto {
|
||||||
|
|
@ -271,6 +272,10 @@ export class SearchSuggestionRequestDto {
|
||||||
@Optional()
|
@Optional()
|
||||||
model?: string;
|
model?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@Optional()
|
||||||
|
lensModel?: string;
|
||||||
|
|
||||||
@ValidateBoolean({ optional: true })
|
@ValidateBoolean({ optional: true })
|
||||||
@PropertyLifecycle({ addedAt: 'v111.0.0' })
|
@PropertyLifecycle({ addedAt: 'v111.0.0' })
|
||||||
includeNull?: boolean;
|
includeNull?: boolean;
|
||||||
|
|
|
||||||
|
|
@ -290,3 +290,15 @@ where
|
||||||
and "visibility" = $2
|
and "visibility" = $2
|
||||||
and "deletedAt" is null
|
and "deletedAt" is null
|
||||||
and "model" is not 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
|
||||||
|
|
|
||||||
|
|
@ -160,10 +160,17 @@ export interface GetCitiesOptions extends GetStatesOptions {
|
||||||
|
|
||||||
export interface GetCameraModelsOptions {
|
export interface GetCameraModelsOptions {
|
||||||
make?: string;
|
make?: string;
|
||||||
|
lensModel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetCameraMakesOptions {
|
export interface GetCameraMakesOptions {
|
||||||
model?: string;
|
model?: string;
|
||||||
|
lensModel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetCameraLensModelsOptions {
|
||||||
|
make?: string;
|
||||||
|
model?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|
@ -457,25 +464,40 @@ export class SearchRepository {
|
||||||
return res.map((row) => row.city!);
|
return res.map((row) => row.city!);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] })
|
@GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING, DummyValue.STRING] })
|
||||||
async getCameraMakes(userIds: string[], { model }: GetCameraMakesOptions): Promise<string[]> {
|
async getCameraMakes(userIds: string[], { model, lensModel }: GetCameraMakesOptions): Promise<string[]> {
|
||||||
const res = await this.getExifField('make', userIds)
|
const res = await this.getExifField('make', userIds)
|
||||||
.$if(!!model, (qb) => qb.where('model', '=', model!))
|
.$if(!!model, (qb) => qb.where('model', '=', model!))
|
||||||
|
.$if(!!lensModel, (qb) => qb.where('lensModel', '=', lensModel!))
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
return res.map((row) => row.make!);
|
return res.map((row) => row.make!);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] })
|
@GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING, DummyValue.STRING] })
|
||||||
async getCameraModels(userIds: string[], { make }: GetCameraModelsOptions): Promise<string[]> {
|
async getCameraModels(userIds: string[], { make, lensModel }: GetCameraModelsOptions): Promise<string[]> {
|
||||||
const res = await this.getExifField('model', userIds)
|
const res = await this.getExifField('model', userIds)
|
||||||
.$if(!!make, (qb) => qb.where('make', '=', make!))
|
.$if(!!make, (qb) => qb.where('make', '=', make!))
|
||||||
|
.$if(!!lensModel, (qb) => qb.where('lensModel', '=', lensModel!))
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
return res.map((row) => row.model!);
|
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
|
return this.db
|
||||||
.selectFrom('asset_exif')
|
.selectFrom('asset_exif')
|
||||||
.select(field)
|
.select(field)
|
||||||
|
|
|
||||||
|
|
@ -179,6 +179,26 @@ describe(SearchService.name, () => {
|
||||||
).resolves.toEqual(['Fujifilm X100VI', null]);
|
).resolves.toEqual(['Fujifilm X100VI', null]);
|
||||||
expect(mocks.search.getCameraModels).toHaveBeenCalledWith([authStub.user1.user.id], expect.anything());
|
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', () => {
|
describe('searchSmart', () => {
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,9 @@ export class SearchService extends BaseService {
|
||||||
case SearchSuggestionType.CAMERA_MODEL: {
|
case SearchSuggestionType.CAMERA_MODEL: {
|
||||||
return this.searchRepository.getCameraModels(userIds, dto);
|
return this.searchRepository.getCameraModels(userIds, dto);
|
||||||
}
|
}
|
||||||
|
case SearchSuggestionType.CAMERA_LENS_MODEL: {
|
||||||
|
return this.searchRepository.getCameraLensModels(userIds, dto);
|
||||||
|
}
|
||||||
default: {
|
default: {
|
||||||
return Promise.resolve([]);
|
return Promise.resolve([]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,11 @@
|
||||||
export interface SearchCameraFilter {
|
export interface SearchCameraFilter {
|
||||||
make?: string;
|
make?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
|
lensModel?: string;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { run } from 'svelte/legacy';
|
|
||||||
|
|
||||||
import Combobox, { asComboboxOptions, asSelectedOption } from '$lib/components/shared-components/combobox.svelte';
|
import Combobox, { asComboboxOptions, asSelectedOption } from '$lib/components/shared-components/combobox.svelte';
|
||||||
import { handlePromiseError } from '$lib/utils';
|
import { handlePromiseError } from '$lib/utils';
|
||||||
import { SearchSuggestionType, getSearchSuggestions } from '@immich/sdk';
|
import { SearchSuggestionType, getSearchSuggestions } from '@immich/sdk';
|
||||||
|
|
@ -21,6 +20,7 @@
|
||||||
|
|
||||||
let makes: string[] = $state([]);
|
let makes: string[] = $state([]);
|
||||||
let models: string[] = $state([]);
|
let models: string[] = $state([]);
|
||||||
|
let lensModels: string[] = $state([]);
|
||||||
|
|
||||||
async function updateMakes() {
|
async function updateMakes() {
|
||||||
const results: Array<string | null> = await getSearchSuggestions({
|
const results: Array<string | null> = await getSearchSuggestions({
|
||||||
|
|
@ -48,14 +48,35 @@
|
||||||
filters.model = undefined;
|
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 makeFilter = $derived(filters.make);
|
||||||
let modelFilter = $derived(filters.model);
|
let modelFilter = $derived(filters.model);
|
||||||
run(() => {
|
let lensModelFilter = $derived(filters.lensModel);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
handlePromiseError(updateMakes());
|
handlePromiseError(updateMakes());
|
||||||
});
|
});
|
||||||
run(() => {
|
$effect(() => {
|
||||||
handlePromiseError(updateModels(makeFilter));
|
handlePromiseError(updateModels(makeFilter));
|
||||||
});
|
});
|
||||||
|
$effect(() => {
|
||||||
|
handlePromiseError(updateLensModels(makeFilter, modelFilter));
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div id="camera-selection">
|
<div id="camera-selection">
|
||||||
|
|
@ -81,5 +102,15 @@
|
||||||
selectedOption={asSelectedOption(modelFilter)}
|
selectedOption={asSelectedOption(modelFilter)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,7 @@
|
||||||
camera: {
|
camera: {
|
||||||
make: withNullAsUndefined(searchQuery.make),
|
make: withNullAsUndefined(searchQuery.make),
|
||||||
model: withNullAsUndefined(searchQuery.model),
|
model: withNullAsUndefined(searchQuery.model),
|
||||||
|
lensModel: withNullAsUndefined(searchQuery.lensModel),
|
||||||
},
|
},
|
||||||
date: {
|
date: {
|
||||||
takenAfter: searchQuery.takenAfter ? toStartOfDayDate(searchQuery.takenAfter) : undefined,
|
takenAfter: searchQuery.takenAfter ? toStartOfDayDate(searchQuery.takenAfter) : undefined,
|
||||||
|
|
@ -147,6 +148,7 @@
|
||||||
city: filter.location.city,
|
city: filter.location.city,
|
||||||
make: filter.camera.make,
|
make: filter.camera.make,
|
||||||
model: filter.camera.model,
|
model: filter.camera.model,
|
||||||
|
lensModel: filter.camera.lensModel,
|
||||||
takenAfter: parseOptionalDate(filter.date.takenAfter)?.startOf('day').toISO() || undefined,
|
takenAfter: parseOptionalDate(filter.date.takenAfter)?.startOf('day').toISO() || undefined,
|
||||||
takenBefore: parseOptionalDate(filter.date.takenBefore)?.endOf('day').toISO() || undefined,
|
takenBefore: parseOptionalDate(filter.date.takenBefore)?.endOf('day').toISO() || undefined,
|
||||||
visibility: filter.display.isArchive ? AssetVisibility.Archive : undefined,
|
visibility: filter.display.isArchive ? AssetVisibility.Archive : undefined,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user