This commit is contained in:
Max K. 2026-08-14 15:28:43 -04:00 committed by GitHub
commit 3c379df650
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 212 additions and 2 deletions

View file

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

View file

@ -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": {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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