mirror of
https://github.com/immich-app/immich
synced 2026-08-15 13:03:57 +00:00
Merge c4720f9032 into 190a939af7
This commit is contained in:
commit
3c379df650
13 changed files with 212 additions and 2 deletions
|
|
@ -384,6 +384,42 @@ describe('/search', () => {
|
|||
expect(status).toBe(200);
|
||||
expect(body.assets.items).toEqual([expect.objectContaining({ id: asset.id })]);
|
||||
});
|
||||
|
||||
it('should search by orientation (landscape)', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/search/metadata')
|
||||
.send({ orientation: 'landscape', withExif: true })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.assets).toBeDefined();
|
||||
expect(Array.isArray(body.assets.items)).toBe(true);
|
||||
expect(body.assets.items.length).toBeGreaterThan(0);
|
||||
|
||||
for (const asset of body.assets.items as AssetResponseDto[]) {
|
||||
expect(asset.exifInfo?.exifImageWidth).toBeDefined();
|
||||
expect(asset.exifInfo?.exifImageHeight).toBeDefined();
|
||||
expect((asset.exifInfo?.exifImageWidth ?? 0) > (asset.exifInfo?.exifImageHeight ?? 0)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should search by orientation (portrait)', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.post('/search/metadata')
|
||||
.send({ orientation: 'portrait', withExif: true })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body.assets).toBeDefined();
|
||||
expect(Array.isArray(body.assets.items)).toBe(true);
|
||||
expect(body.assets.items.length).toBeGreaterThan(0);
|
||||
|
||||
for (const asset of body.assets.items as AssetResponseDto[]) {
|
||||
expect(asset.exifInfo?.exifImageWidth).toBeDefined();
|
||||
expect(asset.exifInfo?.exifImageHeight).toBeDefined();
|
||||
expect((asset.exifInfo?.exifImageHeight ?? 0) > (asset.exifInfo?.exifImageWidth ?? 0)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /search/random', () => {
|
||||
|
|
|
|||
7
i18n/en.json
Normal file → Executable file
7
i18n/en.json
Normal file → Executable file
|
|
@ -1206,6 +1206,11 @@
|
|||
"image_alt_text_date_place_2_people": "{isVideo, select, true {Video} other {Image}} taken in {city}, {country} with {person1} and {person2} on {date}",
|
||||
"image_alt_text_date_place_3_people": "{isVideo, select, true {Video} other {Image}} taken in {city}, {country} with {person1}, {person2}, and {person3} on {date}",
|
||||
"image_alt_text_date_place_4_or_more_people": "{isVideo, select, true {Video} other {Image}} taken in {city}, {country} with {person1}, {person2}, and {additionalCount, number} others on {date}",
|
||||
"image_properties": "Image properties",
|
||||
"image_saved_successfully": "Image saved",
|
||||
"image_viewer_page_state_provider_download_started": "Download Started",
|
||||
"image_viewer_page_state_provider_download_success": "Download Success",
|
||||
"image_viewer_page_state_provider_share_error": "Share Error",
|
||||
"immich_logo": "Immich Logo",
|
||||
"immich_web_interface": "Immich Web Interface",
|
||||
"import_from_json": "Import from JSON",
|
||||
|
|
@ -1251,6 +1256,7 @@
|
|||
"language_no_results_title": "No languages found",
|
||||
"language_search_hint": "Search languages...",
|
||||
"language_setting_description": "Select your preferred language",
|
||||
"landscape": "Landscape",
|
||||
"large_files": "Large Files",
|
||||
"last": "Last",
|
||||
"last_months": "{count, plural, one {Last month} other {Last # months}}",
|
||||
|
|
@ -1651,6 +1657,7 @@
|
|||
"plugin_method_filter_type": "Filter",
|
||||
"plugin_method_filter_type_description": "This method can filter events and conditionally prevent subsequent steps from running",
|
||||
"port": "Port",
|
||||
"portrait": "Portrait",
|
||||
"preferences_settings_subtitle": "Manage the app's preferences",
|
||||
"preferences_settings_title": "Preferences",
|
||||
"preparing": "Preparing",
|
||||
|
|
|
|||
|
|
@ -10329,6 +10329,19 @@
|
|||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "orientation",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Filter by image orientation (landscape or portrait)",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"landscape",
|
||||
"portrait"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "personIds",
|
||||
"required": false,
|
||||
|
|
@ -20288,6 +20301,14 @@
|
|||
"default": "desc",
|
||||
"description": "Sort order"
|
||||
},
|
||||
"orientation": {
|
||||
"description": "Filter by image orientation (landscape or portrait)",
|
||||
"enum": [
|
||||
"landscape",
|
||||
"portrait"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"originalFileName": {
|
||||
"description": "Filter by original file name",
|
||||
"type": "string"
|
||||
|
|
@ -21998,6 +22019,14 @@
|
|||
"description": "Filter by OCR text content",
|
||||
"type": "string"
|
||||
},
|
||||
"orientation": {
|
||||
"description": "Filter by image orientation (landscape or portrait)",
|
||||
"enum": [
|
||||
"landscape",
|
||||
"portrait"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"personIds": {
|
||||
"description": "Filter by person IDs",
|
||||
"items": {
|
||||
|
|
@ -23482,6 +23511,14 @@
|
|||
"description": "Filter by OCR text content",
|
||||
"type": "string"
|
||||
},
|
||||
"orientation": {
|
||||
"description": "Filter by image orientation (landscape or portrait)",
|
||||
"enum": [
|
||||
"landscape",
|
||||
"portrait"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"page": {
|
||||
"description": "Page number",
|
||||
"maximum": 9007199254740991,
|
||||
|
|
@ -23767,6 +23804,14 @@
|
|||
"description": "Filter by OCR text content",
|
||||
"type": "string"
|
||||
},
|
||||
"orientation": {
|
||||
"description": "Filter by image orientation (landscape or portrait)",
|
||||
"enum": [
|
||||
"landscape",
|
||||
"portrait"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"personIds": {
|
||||
"description": "Filter by person IDs",
|
||||
"items": {
|
||||
|
|
|
|||
|
|
@ -1658,6 +1658,8 @@ export type MetadataSearchDto = {
|
|||
ocr?: string;
|
||||
/** Sort order */
|
||||
order?: AssetOrder;
|
||||
/** Filter by image orientation (landscape or portrait) */
|
||||
orientation?: Orientation;
|
||||
/** Filter by original file name */
|
||||
originalFileName?: string;
|
||||
/** Filter by original file path */
|
||||
|
|
@ -1777,6 +1779,8 @@ export type RandomSearchDto = {
|
|||
model?: string | null;
|
||||
/** Filter by OCR text content */
|
||||
ocr?: string;
|
||||
/** Filter by image orientation (landscape or portrait) */
|
||||
orientation?: Orientation;
|
||||
/** Filter by person IDs */
|
||||
personIds?: string[];
|
||||
/** Filter by rating [1-5], or null for unrated */
|
||||
|
|
@ -1843,6 +1847,8 @@ export type SmartSearchDto = {
|
|||
model?: string | null;
|
||||
/** Filter by OCR text content */
|
||||
ocr?: string;
|
||||
/** Filter by image orientation (landscape or portrait) */
|
||||
orientation?: Orientation;
|
||||
/** Page number */
|
||||
page?: number;
|
||||
/** Filter by person IDs */
|
||||
|
|
@ -1911,6 +1917,8 @@ export type StatisticsSearchDto = {
|
|||
model?: string | null;
|
||||
/** Filter by OCR text content */
|
||||
ocr?: string;
|
||||
/** Filter by image orientation (landscape or portrait) */
|
||||
orientation?: Orientation;
|
||||
/** Filter by person IDs */
|
||||
personIds?: string[];
|
||||
/** Filter by rating [1-5], or null for unrated */
|
||||
|
|
@ -5710,7 +5718,7 @@ export function getExploreData(opts?: Oazapfts.RequestOpts) {
|
|||
/**
|
||||
* Search large assets
|
||||
*/
|
||||
export function searchLargeAssets({ albumIds, city, country, createdAfter, createdBefore, isEncoded, isFavorite, isMotion, isNotInAlbum, isOffline, lensModel, libraryId, make, minFileSize, model, ocr, personIds, rating, size, state, tagIds, takenAfter, takenBefore, trashedAfter, trashedBefore, $type, updatedAfter, updatedBefore, visibility, withDeleted, withExif }: {
|
||||
export function searchLargeAssets({ albumIds, city, country, createdAfter, createdBefore, isEncoded, isFavorite, isMotion, isNotInAlbum, isOffline, lensModel, libraryId, make, minFileSize, model, ocr, orientation, personIds, rating, size, state, tagIds, takenAfter, takenBefore, trashedAfter, trashedBefore, $type, updatedAfter, updatedBefore, visibility, withDeleted, withExif }: {
|
||||
albumIds?: string[];
|
||||
city?: string | null;
|
||||
country?: string | null;
|
||||
|
|
@ -5727,6 +5735,7 @@ export function searchLargeAssets({ albumIds, city, country, createdAfter, creat
|
|||
minFileSize?: number;
|
||||
model?: string | null;
|
||||
ocr?: string;
|
||||
orientation?: "landscape" | "portrait";
|
||||
personIds?: string[];
|
||||
rating?: number | null;
|
||||
size?: number;
|
||||
|
|
@ -5763,6 +5772,7 @@ export function searchLargeAssets({ albumIds, city, country, createdAfter, creat
|
|||
minFileSize,
|
||||
model,
|
||||
ocr,
|
||||
orientation,
|
||||
personIds,
|
||||
rating,
|
||||
size,
|
||||
|
|
@ -7547,6 +7557,10 @@ export enum JobName {
|
|||
IntegrityDeleteReportType = "IntegrityDeleteReportType",
|
||||
IntegrityDeleteReports = "IntegrityDeleteReports"
|
||||
}
|
||||
export enum Orientation {
|
||||
Landscape = "landscape",
|
||||
Portrait = "portrait"
|
||||
}
|
||||
export enum SearchSuggestionType {
|
||||
Country = "country",
|
||||
State = "state",
|
||||
|
|
|
|||
|
|
@ -115,6 +115,18 @@ describe(SearchController.name, () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should reject orientation as not an enum value', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/search/metadata')
|
||||
.send({ orientation: 'square' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['orientation'], message: expect.stringContaining('Invalid option: expected one of') },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
describe('POST /search/random', () => {
|
||||
it('should reject if withStacked is not a boolean', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
|
|
|
|||
|
|
@ -36,6 +36,10 @@ const BaseSearchSchema = z.object({
|
|||
make: z.string().nullable().optional().describe('Filter by camera make'),
|
||||
model: z.string().nullable().optional().describe('Filter by camera model'),
|
||||
lensModel: z.string().nullable().optional().describe('Filter by lens model'),
|
||||
orientation: z
|
||||
.enum(['landscape', 'portrait'])
|
||||
.optional()
|
||||
.describe('Filter by image orientation (landscape or portrait)'),
|
||||
isNotInAlbum: z.boolean().optional().describe('Filter assets not in any album'),
|
||||
personIds: z.array(z.uuidv4()).optional().describe('Filter by person IDs'),
|
||||
tagIds: z.array(z.uuidv4()).nullish().describe('Filter by tag IDs'),
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ export interface SearchExifOptions {
|
|||
lensModel?: string | null;
|
||||
make?: string | null;
|
||||
model?: string | null;
|
||||
orientation?: 'landscape' | 'portrait';
|
||||
state?: string | null;
|
||||
description?: string | null;
|
||||
rating?: number | null;
|
||||
|
|
|
|||
|
|
@ -455,6 +455,8 @@ export function searchAssetBuilderLegacy(kysely: Kysely<DB>, options: AssetSearc
|
|||
.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
|
||||
.where('asset_exif.rating', options.rating === null ? 'is' : '=', options.rating!),
|
||||
)
|
||||
.$if(options.orientation === 'landscape', (qb) => qb.whereRef('asset.width', '>', 'asset.height'))
|
||||
.$if(options.orientation === 'portrait', (qb) => qb.whereRef('asset.height', '>', 'asset.width'))
|
||||
.$if(!!options.checksum, (qb) => qb.where('asset.checksum', '=', options.checksum!))
|
||||
.$if(!!options.id, (qb) => qb.where('asset.id', '=', asUuid(options.id!)))
|
||||
.$if(!!options.libraryId, (qb) => qb.where('asset.libraryId', '=', asUuid(options.libraryId!)))
|
||||
|
|
|
|||
|
|
@ -192,6 +192,36 @@ describe(SearchService.name, () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('orientation filter', () => {
|
||||
it('should filter landscape assets', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
|
||||
const { asset: landscapeAsset } = await ctx.newAsset({ ownerId: user.id, width: 4000, height: 3000 });
|
||||
await ctx.newAsset({ ownerId: user.id, width: 3000, height: 4000 });
|
||||
await ctx.newAsset({ ownerId: user.id, width: 3000, height: 3000 });
|
||||
|
||||
const auth = factory.auth({ user: { id: user.id } });
|
||||
const response = await sut.searchMetadata(auth, { orientation: 'landscape' });
|
||||
|
||||
expect(response.assets.items).toEqual([expect.objectContaining({ id: landscapeAsset.id })]);
|
||||
});
|
||||
|
||||
it('should filter portrait assets', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
|
||||
await ctx.newAsset({ ownerId: user.id, width: 4000, height: 3000 });
|
||||
const { asset: portraitAsset } = await ctx.newAsset({ ownerId: user.id, width: 3000, height: 4000 });
|
||||
await ctx.newAsset({ ownerId: user.id, width: 3000, height: 3000 });
|
||||
|
||||
const auth = factory.auth({ user: { id: user.id } });
|
||||
const response = await sut.searchMetadata(auth, { orientation: 'portrait' });
|
||||
|
||||
expect(response.assets.items).toEqual([expect.objectContaining({ id: portraitAsset.id })]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSearchSuggestions', () => {
|
||||
it('should filter out empty search suggestions', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
<script lang="ts">
|
||||
import Combobox from '$lib/components/shared-components/Combobox.svelte';
|
||||
import type { SearchImagePropsFilter } from '$lib/types';
|
||||
import { Text } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
filters: SearchImagePropsFilter;
|
||||
};
|
||||
|
||||
let { filters = $bindable() }: Props = $props();
|
||||
|
||||
const orientationOptions = $derived([
|
||||
{ value: 'landscape', label: $t('landscape') },
|
||||
{ value: 'portrait', label: $t('portrait') },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<div id="image-selection">
|
||||
<Text fontWeight="medium">{$t('image_properties')}</Text>
|
||||
|
||||
<div class="mt-1 grid grid-auto-fit-40 gap-5">
|
||||
<div class="w-1/3">
|
||||
<Combobox
|
||||
label={$t('orientation')}
|
||||
onSelect={(option) => (filters.orientation = option?.value as SearchImagePropsFilter['orientation'])}
|
||||
options={orientationOptions}
|
||||
placeholder={$t('orientation')}
|
||||
selectedOption={orientationOptions.find((option) => option.value === filters.orientation)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -8,12 +8,13 @@
|
|||
import SearchRatingsSection from '$lib/components/shared-components/search-bar/SearchRatingsSection.svelte';
|
||||
import SearchTagsSection from '$lib/components/shared-components/search-bar/SearchTagsSection.svelte';
|
||||
import SearchTextSection from '$lib/components/shared-components/search-bar/SearchTextSection.svelte';
|
||||
import SearchImagePropsSection from '$lib/components/shared-components/search-bar/SearchImagePropsSection.svelte';
|
||||
import { MediaType, QueryType, validQueryTypes } from '$lib/constants';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import type { SearchFilter } from '$lib/types';
|
||||
import { asLocalTimeISO, parseUtcDate } from '$lib/utils/date-time';
|
||||
import { generateId } from '$lib/utils/generate-id';
|
||||
import { AssetTypeEnum, AssetVisibility, type MetadataSearchDto, type SmartSearchDto } from '@immich/sdk';
|
||||
import { AssetTypeEnum, AssetVisibility, Orientation, type MetadataSearchDto, type SmartSearchDto } from '@immich/sdk';
|
||||
import { Button, HStack, Modal, ModalBody, ModalFooter } from '@immich/ui';
|
||||
import { mdiTune } from '@mdi/js';
|
||||
import type { DateTime } from 'luxon';
|
||||
|
|
@ -93,6 +94,9 @@
|
|||
? MediaType.Video
|
||||
: MediaType.All,
|
||||
rating: searchQuery.rating,
|
||||
imageProperties: {
|
||||
orientation: withNullAsUndefined(searchQuery.orientation),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -115,6 +119,9 @@
|
|||
},
|
||||
mediaType: MediaType.All,
|
||||
rating: undefined,
|
||||
imageProperties: {
|
||||
orientation: undefined,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -150,6 +157,12 @@
|
|||
visibility: filter.display.isArchive ? AssetVisibility.Archive : undefined,
|
||||
isFavorite: filter.display.isFavorite || undefined,
|
||||
isNotInAlbum: filter.display.isNotInAlbum || undefined,
|
||||
orientation:
|
||||
filter.imageProperties.orientation === 'landscape'
|
||||
? Orientation.Landscape
|
||||
: filter.imageProperties.orientation === 'portrait'
|
||||
? Orientation.Portrait
|
||||
: undefined,
|
||||
personIds: filter.personIds.size > 0 ? [...filter.personIds] : undefined,
|
||||
tagIds: filter.tagIds === null ? null : filter.tagIds.size > 0 ? [...filter.tagIds] : undefined,
|
||||
type,
|
||||
|
|
@ -210,6 +223,9 @@
|
|||
<!-- DISPLAY OPTIONS -->
|
||||
<SearchDisplaySection bind:filters={filter.display} />
|
||||
</div>
|
||||
|
||||
<!-- IMAGE PROPERTIES -->
|
||||
<SearchImagePropsSection bind:filters={filter.imageProperties} />
|
||||
</div>
|
||||
</form>
|
||||
</ModalBody>
|
||||
|
|
|
|||
|
|
@ -63,6 +63,10 @@ export type SearchDisplayFilters = {
|
|||
isFavorite: boolean;
|
||||
};
|
||||
|
||||
export type SearchImagePropsFilter = {
|
||||
orientation?: 'landscape' | 'portrait';
|
||||
};
|
||||
|
||||
export type SearchLocationFilter = {
|
||||
country?: string;
|
||||
state?: string;
|
||||
|
|
@ -82,6 +86,7 @@ export type SearchFilter = {
|
|||
display: SearchDisplayFilters;
|
||||
mediaType: MediaType;
|
||||
rating?: number | null;
|
||||
imageProperties: SearchImagePropsFilter;
|
||||
};
|
||||
|
||||
export type JSONSchemaType = 'string' | 'number' | 'integer' | 'boolean' | 'object';
|
||||
|
|
|
|||
|
|
@ -192,6 +192,7 @@
|
|||
description: $t('description'),
|
||||
queryAssetId: $t('query_asset_id'),
|
||||
ocr: $t('ocr'),
|
||||
orientation: $t('orientation'),
|
||||
};
|
||||
return keyMap[key] || key;
|
||||
}
|
||||
|
|
@ -279,6 +280,10 @@
|
|||
{/await}
|
||||
{:else if searchKey === 'rating'}
|
||||
{$t('rating_count', { values: { count: value ?? 0 } })}
|
||||
{:else if searchKey === 'orientation' && value === 'landscape'}
|
||||
{$t('landscape')}
|
||||
{:else if searchKey === 'orientation' && value === 'portrait'}
|
||||
{$t('portrait')}
|
||||
{:else if value === null || value === ''}
|
||||
{$t('unknown')}
|
||||
{:else}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue