diff --git a/e2e/src/specs/server/api/search.e2e-spec.ts b/e2e/src/specs/server/api/search.e2e-spec.ts index 47c88c238c..dcc3808de3 100644 --- a/e2e/src/specs/server/api/search.e2e-spec.ts +++ b/e2e/src/specs/server/api/search.e2e-spec.ts @@ -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', () => { diff --git a/i18n/en.json b/i18n/en.json old mode 100644 new mode 100755 index 0acfe68fa3..7e799f017d --- a/i18n/en.json +++ b/i18n/en.json @@ -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", diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index d00faab9a0..0aebbc540b 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -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": { diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index b2f98b58ef..b5a7d4d4f1 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -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", diff --git a/server/src/controllers/search.controller.spec.ts b/server/src/controllers/search.controller.spec.ts index f1ac01ed41..b25b2312d2 100644 --- a/server/src/controllers/search.controller.spec.ts +++ b/server/src/controllers/search.controller.spec.ts @@ -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()) diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index 7911f92f44..97e2394269 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -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'), diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 70ba09ef2c..5e5b3e99a8 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -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; diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 0f4d8775b6..78cce6c096 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -455,6 +455,8 @@ export function searchAssetBuilderLegacy(kysely: Kysely, 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!))) diff --git a/server/test/medium/specs/services/search.service.spec.ts b/server/test/medium/specs/services/search.service.spec.ts index 044cd8d4f5..7d7767ea43 100644 --- a/server/test/medium/specs/services/search.service.spec.ts +++ b/server/test/medium/specs/services/search.service.spec.ts @@ -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(); diff --git a/web/src/lib/components/shared-components/search-bar/SearchImagePropsSection.svelte b/web/src/lib/components/shared-components/search-bar/SearchImagePropsSection.svelte new file mode 100644 index 0000000000..dbed649dee --- /dev/null +++ b/web/src/lib/components/shared-components/search-bar/SearchImagePropsSection.svelte @@ -0,0 +1,33 @@ + + +
+ {$t('image_properties')} + +
+
+ (filters.orientation = option?.value as SearchImagePropsFilter['orientation'])} + options={orientationOptions} + placeholder={$t('orientation')} + selectedOption={orientationOptions.find((option) => option.value === filters.orientation)} + /> +
+
+
diff --git a/web/src/lib/modals/SearchFilterModal.svelte b/web/src/lib/modals/SearchFilterModal.svelte index 2acb885d4a..87c8e8f3f6 100644 --- a/web/src/lib/modals/SearchFilterModal.svelte +++ b/web/src/lib/modals/SearchFilterModal.svelte @@ -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 @@ + + + diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index f2451b1bc5..f972615a98 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -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'; diff --git a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte index 6683dbfe2e..ce35b7b21e 100644 --- a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -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}