From 34b88bb47a4849423bc6935bb3a52a43ba9a1398 Mon Sep 17 00:00:00 2001 From: Jason Antwi-Appah Date: Thu, 20 Feb 2025 10:17:06 -0600 Subject: [PATCH] feat(web): support searching by EXIF rating (#16208) * Add rating to search DTO * Add search by EXIF rating in search query builder * Generate OpenAPI spec * Add rating filter on web * Add rating filter to search docs * Format / lint * Hide rating filter if ratings are disabled * chore: component order in form --------- Co-authored-by: Alex Tran --- docs/docs/features/searching.md | 1 + i18n/en.json | 1 + .../lib/model/metadata_search_dto.dart | 21 ++++++++++++- .../openapi/lib/model/random_search_dto.dart | 21 ++++++++++++- .../openapi/lib/model/smart_search_dto.dart | 21 ++++++++++++- open-api/immich-openapi-specs.json | 15 +++++++++ open-api/typescript-sdk/src/fetch-client.ts | 3 ++ server/src/dtos/search.dto.ts | 6 ++++ server/src/entities/asset.entity.ts | 5 +++ server/src/repositories/search.repository.ts | 1 + .../search-bar/search-filter-modal.svelte | 11 +++++++ .../search-bar/search-ratings-section.svelte | 31 +++++++++++++++++++ 12 files changed, 134 insertions(+), 3 deletions(-) create mode 100644 web/src/lib/components/shared-components/search-bar/search-ratings-section.svelte diff --git a/docs/docs/features/searching.md b/docs/docs/features/searching.md index 13547f6bac..eed5faa6fb 100644 --- a/docs/docs/features/searching.md +++ b/docs/docs/features/searching.md @@ -31,6 +31,7 @@ The filters smart search allows you to search by include: - Not in any album - Archived - Favorited + - Rating diff --git a/i18n/en.json b/i18n/en.json index 72559d4502..b6f75ce4f1 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1134,6 +1134,7 @@ "search_timezone": "Search timezone...", "search_type": "Search type", "search_your_photos": "Search your photos", + "search_rating": "Search by rating...", "searching_locales": "Searching locales...", "second": "Second", "see_all_people": "See all people", diff --git a/mobile/openapi/lib/model/metadata_search_dto.dart b/mobile/openapi/lib/model/metadata_search_dto.dart index 3a3c141442..3fb003d164 100644 --- a/mobile/openapi/lib/model/metadata_search_dto.dart +++ b/mobile/openapi/lib/model/metadata_search_dto.dart @@ -40,6 +40,7 @@ class MetadataSearchDto { this.page, this.personIds = const [], this.previewPath, + this.rating, this.size, this.state, this.tagIds = const [], @@ -233,6 +234,16 @@ class MetadataSearchDto { /// String? previewPath; + /// Minimum value: -1 + /// Maximum value: 5 + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + num? rating; + /// Minimum value: 1 /// Maximum value: 1000 /// @@ -374,6 +385,7 @@ class MetadataSearchDto { other.page == page && _deepEquality.equals(other.personIds, personIds) && other.previewPath == previewPath && + other.rating == rating && other.size == size && other.state == state && _deepEquality.equals(other.tagIds, tagIds) && @@ -421,6 +433,7 @@ class MetadataSearchDto { (page == null ? 0 : page!.hashCode) + (personIds.hashCode) + (previewPath == null ? 0 : previewPath!.hashCode) + + (rating == null ? 0 : rating!.hashCode) + (size == null ? 0 : size!.hashCode) + (state == null ? 0 : state!.hashCode) + (tagIds.hashCode) + @@ -439,7 +452,7 @@ class MetadataSearchDto { (withStacked == null ? 0 : withStacked!.hashCode); @override - String toString() => 'MetadataSearchDto[checksum=$checksum, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, description=$description, deviceAssetId=$deviceAssetId, deviceId=$deviceId, encodedVideoPath=$encodedVideoPath, id=$id, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, order=$order, originalFileName=$originalFileName, originalPath=$originalPath, page=$page, personIds=$personIds, previewPath=$previewPath, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, thumbnailPath=$thumbnailPath, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; + String toString() => 'MetadataSearchDto[checksum=$checksum, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, description=$description, deviceAssetId=$deviceAssetId, deviceId=$deviceId, encodedVideoPath=$encodedVideoPath, id=$id, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, order=$order, originalFileName=$originalFileName, originalPath=$originalPath, page=$page, personIds=$personIds, previewPath=$previewPath, rating=$rating, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, thumbnailPath=$thumbnailPath, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; Map toJson() { final json = {}; @@ -570,6 +583,11 @@ class MetadataSearchDto { } else { // json[r'previewPath'] = null; } + if (this.rating != null) { + json[r'rating'] = this.rating; + } else { + // json[r'rating'] = null; + } if (this.size != null) { json[r'size'] = this.size; } else { @@ -683,6 +701,7 @@ class MetadataSearchDto { ? (json[r'personIds'] as Iterable).cast().toList(growable: false) : const [], previewPath: mapValueOfType(json, r'previewPath'), + rating: num.parse('${json[r'rating']}'), size: num.parse('${json[r'size']}'), state: mapValueOfType(json, r'state'), tagIds: json[r'tagIds'] is Iterable diff --git a/mobile/openapi/lib/model/random_search_dto.dart b/mobile/openapi/lib/model/random_search_dto.dart index c63d7e82f6..10727ec10d 100644 --- a/mobile/openapi/lib/model/random_search_dto.dart +++ b/mobile/openapi/lib/model/random_search_dto.dart @@ -30,6 +30,7 @@ class RandomSearchDto { this.make, this.model, this.personIds = const [], + this.rating, this.size, this.state, this.tagIds = const [], @@ -147,6 +148,16 @@ class RandomSearchDto { List personIds; + /// Minimum value: -1 + /// Maximum value: 5 + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + num? rating; + /// Minimum value: 1 /// Maximum value: 1000 /// @@ -270,6 +281,7 @@ class RandomSearchDto { other.make == make && other.model == model && _deepEquality.equals(other.personIds, personIds) && + other.rating == rating && other.size == size && other.state == state && _deepEquality.equals(other.tagIds, tagIds) && @@ -306,6 +318,7 @@ class RandomSearchDto { (make == null ? 0 : make!.hashCode) + (model == null ? 0 : model!.hashCode) + (personIds.hashCode) + + (rating == null ? 0 : rating!.hashCode) + (size == null ? 0 : size!.hashCode) + (state == null ? 0 : state!.hashCode) + (tagIds.hashCode) + @@ -323,7 +336,7 @@ class RandomSearchDto { (withStacked == null ? 0 : withStacked!.hashCode); @override - String toString() => 'RandomSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, personIds=$personIds, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; + String toString() => 'RandomSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, personIds=$personIds, rating=$rating, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; Map toJson() { final json = {}; @@ -408,6 +421,11 @@ class RandomSearchDto { // json[r'model'] = null; } json[r'personIds'] = this.personIds; + if (this.rating != null) { + json[r'rating'] = this.rating; + } else { + // json[r'rating'] = null; + } if (this.size != null) { json[r'size'] = this.size; } else { @@ -506,6 +524,7 @@ class RandomSearchDto { personIds: json[r'personIds'] is Iterable ? (json[r'personIds'] as Iterable).cast().toList(growable: false) : const [], + rating: num.parse('${json[r'rating']}'), size: num.parse('${json[r'size']}'), state: mapValueOfType(json, r'state'), tagIds: json[r'tagIds'] is Iterable diff --git a/mobile/openapi/lib/model/smart_search_dto.dart b/mobile/openapi/lib/model/smart_search_dto.dart index c81e1519b4..f377c23f22 100644 --- a/mobile/openapi/lib/model/smart_search_dto.dart +++ b/mobile/openapi/lib/model/smart_search_dto.dart @@ -32,6 +32,7 @@ class SmartSearchDto { this.page, this.personIds = const [], required this.query, + this.rating, this.size, this.state, this.tagIds = const [], @@ -158,6 +159,16 @@ class SmartSearchDto { String query; + /// Minimum value: -1 + /// Maximum value: 5 + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + num? rating; + /// Minimum value: 1 /// Maximum value: 1000 /// @@ -267,6 +278,7 @@ class SmartSearchDto { other.page == page && _deepEquality.equals(other.personIds, personIds) && other.query == query && + other.rating == rating && other.size == size && other.state == state && _deepEquality.equals(other.tagIds, tagIds) && @@ -303,6 +315,7 @@ class SmartSearchDto { (page == null ? 0 : page!.hashCode) + (personIds.hashCode) + (query.hashCode) + + (rating == null ? 0 : rating!.hashCode) + (size == null ? 0 : size!.hashCode) + (state == null ? 0 : state!.hashCode) + (tagIds.hashCode) + @@ -318,7 +331,7 @@ class SmartSearchDto { (withExif == null ? 0 : withExif!.hashCode); @override - String toString() => 'SmartSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, page=$page, personIds=$personIds, query=$query, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif]'; + String toString() => 'SmartSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, page=$page, personIds=$personIds, query=$query, rating=$rating, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif]'; Map toJson() { final json = {}; @@ -409,6 +422,11 @@ class SmartSearchDto { } json[r'personIds'] = this.personIds; json[r'query'] = this.query; + if (this.rating != null) { + json[r'rating'] = this.rating; + } else { + // json[r'rating'] = null; + } if (this.size != null) { json[r'size'] = this.size; } else { @@ -499,6 +517,7 @@ class SmartSearchDto { ? (json[r'personIds'] as Iterable).cast().toList(growable: false) : const [], query: mapValueOfType(json, r'query')!, + rating: num.parse('${json[r'rating']}'), size: num.parse('${json[r'size']}'), state: mapValueOfType(json, r'state'), tagIds: json[r'tagIds'] is Iterable diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 5b5c3a1503..14245e11bd 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -9956,6 +9956,11 @@ "previewPath": { "type": "string" }, + "rating": { + "maximum": 5, + "minimum": -1, + "type": "number" + }, "size": { "maximum": 1000, "minimum": 1, @@ -10613,6 +10618,11 @@ }, "type": "array" }, + "rating": { + "maximum": 5, + "minimum": -1, + "type": "number" + }, "size": { "maximum": 1000, "minimum": 1, @@ -11563,6 +11573,11 @@ "query": { "type": "string" }, + "rating": { + "maximum": 5, + "minimum": -1, + "type": "number" + }, "size": { "maximum": 1000, "minimum": 1, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index d4b36a04f0..9ff35331fb 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -811,6 +811,7 @@ export type MetadataSearchDto = { page?: number; personIds?: string[]; previewPath?: string; + rating?: number; size?: number; state?: string | null; tagIds?: string[]; @@ -878,6 +879,7 @@ export type RandomSearchDto = { make?: string; model?: string | null; personIds?: string[]; + rating?: number; size?: number; state?: string | null; tagIds?: string[]; @@ -914,6 +916,7 @@ export type SmartSearchDto = { page?: number; personIds?: string[]; query: string; + rating?: number; size?: number; state?: string | null; tagIds?: string[]; diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index 6cf34debef..3589331c78 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -114,6 +114,12 @@ class BaseSearchDto { @ValidateUUID({ each: true, optional: true }) tagIds?: string[]; + + @Optional() + @IsInt() + @Max(5) + @Min(-1) + rating?: number; } export class RandomSearchDto extends BaseSearchDto { diff --git a/server/src/entities/asset.entity.ts b/server/src/entities/asset.entity.ts index 8ff4130edd..fd69673eb5 100644 --- a/server/src/entities/asset.entity.ts +++ b/server/src/entities/asset.entity.ts @@ -387,6 +387,11 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild .innerJoin('exif', 'assets.id', 'exif.assetId') .where('exif.lensModel', options.lensModel === null ? 'is' : '=', options.lensModel!), ) + .$if(options.rating !== undefined, (qb) => + qb + .innerJoin('exif', 'assets.id', 'exif.assetId') + .where('exif.rating', options.rating === null ? 'is' : '=', options.rating!), + ) .$if(!!options.checksum, (qb) => qb.where('assets.checksum', '=', options.checksum!)) .$if(!!options.deviceAssetId, (qb) => qb.where('assets.deviceAssetId', '=', options.deviceAssetId!)) .$if(!!options.deviceId, (qb) => qb.where('assets.deviceId', '=', options.deviceId!)) diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index a6eb5c7a85..2f313aa083 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -109,6 +109,7 @@ export interface SearchExifOptions { model?: string | null; state?: string | null; description?: string | null; + rating?: number | null; } export interface SearchEmbeddingOptions { diff --git a/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte b/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte index 8170010332..4fc646b204 100644 --- a/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte @@ -14,6 +14,7 @@ date: SearchDateFilter; display: SearchDisplayFilters; mediaType: MediaType; + rating?: number; }; @@ -26,6 +27,7 @@ import SearchCameraSection, { type SearchCameraFilter } from './search-camera-section.svelte'; import SearchDateSection from './search-date-section.svelte'; import SearchMediaSection from './search-media-section.svelte'; + import SearchRatingsSection from './search-ratings-section.svelte'; import { parseUtcDate } from '$lib/utils/date-time'; import SearchDisplaySection from './search-display-section.svelte'; import SearchTextSection from './search-text-section.svelte'; @@ -34,6 +36,7 @@ import { mdiTune } from '@mdi/js'; import { generateId } from '$lib/utils/generate-id'; import { SvelteSet } from 'svelte/reactivity'; + import { preferences } from '$lib/stores/user.store'; interface Props { searchQuery: MetadataSearchDto | SmartSearchDto; @@ -81,6 +84,7 @@ : searchQuery.type === AssetTypeEnum.Video ? MediaType.Video : MediaType.All, + rating: searchQuery.rating, }); const resetForm = () => { @@ -94,6 +98,7 @@ date: {}, display: {}, mediaType: MediaType.All, + rating: undefined, }; }; @@ -124,6 +129,7 @@ personIds: filter.personIds.size > 0 ? [...filter.personIds] : undefined, tagIds: filter.tagIds.size > 0 ? [...filter.tagIds] : undefined, type, + rating: filter.rating, }; onSearch(payload); @@ -161,6 +167,11 @@ + + {#if $preferences?.ratings.enabled} + + {/if} +
diff --git a/web/src/lib/components/shared-components/search-bar/search-ratings-section.svelte b/web/src/lib/components/shared-components/search-bar/search-ratings-section.svelte new file mode 100644 index 0000000000..00e6223807 --- /dev/null +++ b/web/src/lib/components/shared-components/search-bar/search-ratings-section.svelte @@ -0,0 +1,31 @@ + + +
+ +