diff --git a/mobile/openapi/lib/model/metadata_search_dto.dart b/mobile/openapi/lib/model/metadata_search_dto.dart index 7f1184467b..520777a45d 100644 --- a/mobile/openapi/lib/model/metadata_search_dto.dart +++ b/mobile/openapi/lib/model/metadata_search_dto.dart @@ -13,6 +13,7 @@ part of openapi.api; class MetadataSearchDto { /// Returns a new [MetadataSearchDto] instance. MetadataSearchDto({ + this.albumIds = const [], this.checksum, this.city, this.country, @@ -57,6 +58,8 @@ class MetadataSearchDto { this.withStacked, }); + List albumIds; + /// /// 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 @@ -346,6 +349,7 @@ class MetadataSearchDto { @override bool operator ==(Object other) => identical(this, other) || other is MetadataSearchDto && + _deepEquality.equals(other.albumIds, albumIds) && other.checksum == checksum && other.city == city && other.country == country && @@ -392,6 +396,7 @@ class MetadataSearchDto { @override int get hashCode => // ignore: unnecessary_parenthesis + (albumIds.hashCode) + (checksum == null ? 0 : checksum!.hashCode) + (city == null ? 0 : city!.hashCode) + (country == null ? 0 : country!.hashCode) + @@ -436,10 +441,11 @@ 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, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, 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, visibility=$visibility, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; + String toString() => 'MetadataSearchDto[albumIds=$albumIds, checksum=$checksum, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, description=$description, deviceAssetId=$deviceAssetId, deviceId=$deviceId, encodedVideoPath=$encodedVideoPath, id=$id, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, 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, visibility=$visibility, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; Map toJson() { final json = {}; + json[r'albumIds'] = this.albumIds; if (this.checksum != null) { json[r'checksum'] = this.checksum; } else { @@ -650,6 +656,9 @@ class MetadataSearchDto { final json = value.cast(); return MetadataSearchDto( + albumIds: json[r'albumIds'] is Iterable + ? (json[r'albumIds'] as Iterable).cast().toList(growable: false) + : const [], checksum: mapValueOfType(json, r'checksum'), city: mapValueOfType(json, r'city'), country: mapValueOfType(json, r'country'), diff --git a/mobile/openapi/lib/model/random_search_dto.dart b/mobile/openapi/lib/model/random_search_dto.dart index 0284212efc..c5914f9fa3 100644 --- a/mobile/openapi/lib/model/random_search_dto.dart +++ b/mobile/openapi/lib/model/random_search_dto.dart @@ -13,6 +13,7 @@ part of openapi.api; class RandomSearchDto { /// Returns a new [RandomSearchDto] instance. RandomSearchDto({ + this.albumIds = const [], this.city, this.country, this.createdAfter, @@ -46,6 +47,8 @@ class RandomSearchDto { this.withStacked, }); + List albumIds; + String? city; String? country; @@ -252,6 +255,7 @@ class RandomSearchDto { @override bool operator ==(Object other) => identical(this, other) || other is RandomSearchDto && + _deepEquality.equals(other.albumIds, albumIds) && other.city == city && other.country == country && other.createdAfter == createdAfter && @@ -287,6 +291,7 @@ class RandomSearchDto { @override int get hashCode => // ignore: unnecessary_parenthesis + (albumIds.hashCode) + (city == null ? 0 : city!.hashCode) + (country == null ? 0 : country!.hashCode) + (createdAfter == null ? 0 : createdAfter!.hashCode) + @@ -320,10 +325,11 @@ class RandomSearchDto { (withStacked == null ? 0 : withStacked!.hashCode); @override - String toString() => 'RandomSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, 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, visibility=$visibility, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; + String toString() => 'RandomSearchDto[albumIds=$albumIds, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, 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, visibility=$visibility, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; Map toJson() { final json = {}; + json[r'albumIds'] = this.albumIds; if (this.city != null) { json[r'city'] = this.city; } else { @@ -483,6 +489,9 @@ class RandomSearchDto { final json = value.cast(); return RandomSearchDto( + albumIds: json[r'albumIds'] is Iterable + ? (json[r'albumIds'] as Iterable).cast().toList(growable: false) + : const [], city: mapValueOfType(json, r'city'), country: mapValueOfType(json, r'country'), createdAfter: mapDateTime(json, r'createdAfter', r''), diff --git a/mobile/openapi/lib/model/smart_search_dto.dart b/mobile/openapi/lib/model/smart_search_dto.dart index a915d97b31..c221340553 100644 --- a/mobile/openapi/lib/model/smart_search_dto.dart +++ b/mobile/openapi/lib/model/smart_search_dto.dart @@ -13,6 +13,7 @@ part of openapi.api; class SmartSearchDto { /// Returns a new [SmartSearchDto] instance. SmartSearchDto({ + this.albumIds = const [], this.city, this.country, this.createdAfter, @@ -47,6 +48,8 @@ class SmartSearchDto { this.withExif, }); + List albumIds; + String? city; String? country; @@ -256,6 +259,7 @@ class SmartSearchDto { @override bool operator ==(Object other) => identical(this, other) || other is SmartSearchDto && + _deepEquality.equals(other.albumIds, albumIds) && other.city == city && other.country == country && other.createdAfter == createdAfter && @@ -292,6 +296,7 @@ class SmartSearchDto { @override int get hashCode => // ignore: unnecessary_parenthesis + (albumIds.hashCode) + (city == null ? 0 : city!.hashCode) + (country == null ? 0 : country!.hashCode) + (createdAfter == null ? 0 : createdAfter!.hashCode) + @@ -326,10 +331,11 @@ class SmartSearchDto { (withExif == null ? 0 : withExif!.hashCode); @override - String toString() => 'SmartSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, language=$language, 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, visibility=$visibility, withDeleted=$withDeleted, withExif=$withExif]'; + String toString() => 'SmartSearchDto[albumIds=$albumIds, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, language=$language, 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, visibility=$visibility, withDeleted=$withDeleted, withExif=$withExif]'; Map toJson() { final json = {}; + json[r'albumIds'] = this.albumIds; if (this.city != null) { json[r'city'] = this.city; } else { @@ -490,6 +496,9 @@ class SmartSearchDto { final json = value.cast(); return SmartSearchDto( + albumIds: json[r'albumIds'] is Iterable + ? (json[r'albumIds'] as Iterable).cast().toList(growable: false) + : const [], city: mapValueOfType(json, r'city'), country: mapValueOfType(json, r'country'), createdAfter: mapDateTime(json, r'createdAfter', r''), diff --git a/mobile/openapi/lib/model/statistics_search_dto.dart b/mobile/openapi/lib/model/statistics_search_dto.dart index 0fe0770b6d..55de23ba32 100644 --- a/mobile/openapi/lib/model/statistics_search_dto.dart +++ b/mobile/openapi/lib/model/statistics_search_dto.dart @@ -13,6 +13,7 @@ part of openapi.api; class StatisticsSearchDto { /// Returns a new [StatisticsSearchDto] instance. StatisticsSearchDto({ + this.albumIds = const [], this.city, this.country, this.createdAfter, @@ -42,6 +43,8 @@ class StatisticsSearchDto { this.visibility, }); + List albumIds; + String? city; String? country; @@ -214,6 +217,7 @@ class StatisticsSearchDto { @override bool operator ==(Object other) => identical(this, other) || other is StatisticsSearchDto && + _deepEquality.equals(other.albumIds, albumIds) && other.city == city && other.country == country && other.createdAfter == createdAfter && @@ -245,6 +249,7 @@ class StatisticsSearchDto { @override int get hashCode => // ignore: unnecessary_parenthesis + (albumIds.hashCode) + (city == null ? 0 : city!.hashCode) + (country == null ? 0 : country!.hashCode) + (createdAfter == null ? 0 : createdAfter!.hashCode) + @@ -274,10 +279,11 @@ class StatisticsSearchDto { (visibility == null ? 0 : visibility!.hashCode); @override - String toString() => 'StatisticsSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, description=$description, deviceId=$deviceId, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, personIds=$personIds, rating=$rating, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, visibility=$visibility]'; + String toString() => 'StatisticsSearchDto[albumIds=$albumIds, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, description=$description, deviceId=$deviceId, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, personIds=$personIds, rating=$rating, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, visibility=$visibility]'; Map toJson() { final json = {}; + json[r'albumIds'] = this.albumIds; if (this.city != null) { json[r'city'] = this.city; } else { @@ -417,6 +423,9 @@ class StatisticsSearchDto { final json = value.cast(); return StatisticsSearchDto( + albumIds: json[r'albumIds'] is Iterable + ? (json[r'albumIds'] as Iterable).cast().toList(growable: false) + : const [], city: mapValueOfType(json, r'city'), country: mapValueOfType(json, r'country'), createdAfter: mapDateTime(json, r'createdAfter', r''), diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 352fe768f8..775fe117ad 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -10866,6 +10866,13 @@ }, "MetadataSearchDto": { "properties": { + "albumIds": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + }, "checksum": { "type": "string" }, @@ -11785,6 +11792,13 @@ }, "RandomSearchDto": { "properties": { + "albumIds": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + }, "city": { "nullable": true, "type": "string" @@ -12833,6 +12847,13 @@ }, "SmartSearchDto": { "properties": { + "albumIds": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + }, "city": { "nullable": true, "type": "string" @@ -13029,6 +13050,13 @@ }, "StatisticsSearchDto": { "properties": { + "albumIds": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + }, "city": { "nullable": true, "type": "string" diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 0308ecb9e0..fee00acffd 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -853,6 +853,7 @@ export type SearchExploreResponseDto = { items: SearchExploreItem[]; }; export type MetadataSearchDto = { + albumIds?: string[]; checksum?: string; city?: string | null; country?: string | null; @@ -929,6 +930,7 @@ export type PlacesResponseDto = { name: string; }; export type RandomSearchDto = { + albumIds?: string[]; city?: string | null; country?: string | null; createdAfter?: string; @@ -962,6 +964,7 @@ export type RandomSearchDto = { withStacked?: boolean; }; export type SmartSearchDto = { + albumIds?: string[]; city?: string | null; country?: string | null; createdAfter?: string; @@ -996,6 +999,7 @@ export type SmartSearchDto = { withExif?: boolean; }; export type StatisticsSearchDto = { + albumIds?: string[]; city?: string | null; country?: string | null; createdAfter?: string; diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index 81d74e0a76..d0427ef322 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -95,6 +95,9 @@ class BaseSearchDto { @ValidateUUID({ each: true, optional: true }) tagIds?: string[]; + @ValidateUUID({ each: true, optional: true }) + albumIds?: string[]; + @Optional() @IsInt() @Max(5) diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 14150d4243..068de1eb4d 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -91,6 +91,10 @@ export interface SearchTagOptions { tagIds?: string[]; } +export interface SearchAlbumOptions { + albumIds?: string[]; +} + export interface SearchOrderOptions { orderDirection?: 'asc' | 'desc'; } @@ -108,7 +112,8 @@ type BaseAssetSearchOptions = SearchDateOptions & SearchStatusOptions & SearchUserIdOptions & SearchPeopleOptions & - SearchTagOptions; + SearchTagOptions & + SearchAlbumOptions; export type AssetSearchOptions = BaseAssetSearchOptions & SearchRelationOptions; diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index d8171dc955..d7417becf1 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -228,6 +228,20 @@ export function hasPeople(qb: SelectQueryBuilder, personIds: ); } +export function inAlbums(qb: SelectQueryBuilder, albumIds: string[]) { + return qb.innerJoin( + (eb) => + eb + .selectFrom('albums_assets_assets') + .select('assetsId') + .where('albumsId', '=', anyUuid(albumIds!)) + .groupBy('assetsId') + .having((eb) => eb.fn.count('albumsId').distinct(), '=', albumIds.length) + .as('has_album'), + (join) => join.onRef('has_album.assetsId', '=', 'assets.id'), + ); +} + export function hasTags(qb: SelectQueryBuilder, tagIds: string[]) { return qb.innerJoin( (eb) => @@ -292,6 +306,7 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild .withPlugin(joinDeduplicationPlugin) .selectFrom('assets') .where('assets.visibility', '=', visibility) + .$if(!!options.albumIds && options.albumIds.length > 0, (qb) => inAlbums(qb, options.albumIds!)) .$if(!!options.tagIds && options.tagIds.length > 0, (qb) => hasTags(qb, options.tagIds!)) .$if(!!options.personIds && options.personIds.length > 0, (qb) => hasPeople(qb, options.personIds!)) .$if(!!options.createdBefore, (qb) => qb.where('assets.createdAt', '<=', options.createdBefore!)) @@ -368,7 +383,7 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild .$if(options.isMotion !== undefined, (qb) => qb.where('assets.livePhotoVideoId', options.isMotion ? 'is not' : 'is', null), ) - .$if(!!options.isNotInAlbum, (qb) => + .$if(!!options.isNotInAlbum && (!options.albumIds || options.albumIds.length === 0), (qb) => qb.where((eb) => eb.not(eb.exists((eb) => eb.selectFrom('albums_assets_assets').whereRef('assetsId', '=', 'assets.id'))), ),