mirror of
https://github.com/immich-app/immich.git
synced 2025-10-26 04:33:39 +09:00
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
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:
@@ -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...",
|
||||
|
||||
13
mobile/openapi/lib/api/search_api.dart
generated
13
mobile/openapi/lib/api/search_api.dart
generated
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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([]);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user