diff --git a/i18n/en.json b/i18n/en.json index 39baff3381..5f7f166222 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1154,6 +1154,7 @@ "language_no_results_title": "No languages found", "language_search_hint": "Search languages...", "language_setting_description": "Select your preferred language", + "large_files": "Large Files", "last_seen": "Last seen", "latest_version": "Latest Version", "latitude": "Latitude", @@ -1588,6 +1589,7 @@ "resume": "Resume", "retry_upload": "Retry upload", "review_duplicates": "Review duplicates", + "review_large_files": "Review large files", "role": "Role", "role_editor": "Editor", "role_viewer": "Viewer", diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 3181b03a47..058524479c 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -184,6 +184,7 @@ Class | Method | HTTP request | Description *SearchApi* | [**getSearchSuggestions**](doc//SearchApi.md#getsearchsuggestions) | **GET** /search/suggestions | *SearchApi* | [**searchAssetStatistics**](doc//SearchApi.md#searchassetstatistics) | **POST** /search/statistics | *SearchApi* | [**searchAssets**](doc//SearchApi.md#searchassets) | **POST** /search/metadata | +*SearchApi* | [**searchLargeAssets**](doc//SearchApi.md#searchlargeassets) | **POST** /search/large-assets | *SearchApi* | [**searchPerson**](doc//SearchApi.md#searchperson) | **GET** /search/person | *SearchApi* | [**searchPlaces**](doc//SearchApi.md#searchplaces) | **GET** /search/places | *SearchApi* | [**searchRandom**](doc//SearchApi.md#searchrandom) | **POST** /search/random | diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart index 5c7a8de59d..1b58702c40 100644 --- a/mobile/openapi/lib/api/search_api.dart +++ b/mobile/openapi/lib/api/search_api.dart @@ -287,6 +287,270 @@ class SearchApi { return null; } + /// Performs an HTTP 'POST /search/large-assets' operation and returns the [Response]. + /// Parameters: + /// + /// * [List] albumIds: + /// + /// * [String] city: + /// + /// * [String] country: + /// + /// * [DateTime] createdAfter: + /// + /// * [DateTime] createdBefore: + /// + /// * [String] deviceId: + /// + /// * [bool] isEncoded: + /// + /// * [bool] isFavorite: + /// + /// * [bool] isMotion: + /// + /// * [bool] isNotInAlbum: + /// + /// * [bool] isOffline: + /// + /// * [String] lensModel: + /// + /// * [String] libraryId: + /// + /// * [String] make: + /// + /// * [int] minFileSize: + /// + /// * [String] model: + /// + /// * [List] personIds: + /// + /// * [num] rating: + /// + /// * [num] size: + /// + /// * [String] state: + /// + /// * [List] tagIds: + /// + /// * [DateTime] takenAfter: + /// + /// * [DateTime] takenBefore: + /// + /// * [DateTime] trashedAfter: + /// + /// * [DateTime] trashedBefore: + /// + /// * [AssetTypeEnum] type: + /// + /// * [DateTime] updatedAfter: + /// + /// * [DateTime] updatedBefore: + /// + /// * [AssetVisibility] visibility: + /// + /// * [bool] withDeleted: + /// + /// * [bool] withExif: + Future searchLargeAssetsWithHttpInfo({ List? albumIds, String? city, String? country, DateTime? createdAfter, DateTime? createdBefore, String? deviceId, bool? isEncoded, bool? isFavorite, bool? isMotion, bool? isNotInAlbum, bool? isOffline, String? lensModel, String? libraryId, String? make, int? minFileSize, String? model, List? personIds, num? rating, num? size, String? state, List? tagIds, DateTime? takenAfter, DateTime? takenBefore, DateTime? trashedAfter, DateTime? trashedBefore, AssetTypeEnum? type, DateTime? updatedAfter, DateTime? updatedBefore, AssetVisibility? visibility, bool? withDeleted, bool? withExif, }) async { + // ignore: prefer_const_declarations + final apiPath = r'/search/large-assets'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + if (albumIds != null) { + queryParams.addAll(_queryParams('multi', 'albumIds', albumIds)); + } + if (city != null) { + queryParams.addAll(_queryParams('', 'city', city)); + } + if (country != null) { + queryParams.addAll(_queryParams('', 'country', country)); + } + if (createdAfter != null) { + queryParams.addAll(_queryParams('', 'createdAfter', createdAfter)); + } + if (createdBefore != null) { + queryParams.addAll(_queryParams('', 'createdBefore', createdBefore)); + } + if (deviceId != null) { + queryParams.addAll(_queryParams('', 'deviceId', deviceId)); + } + if (isEncoded != null) { + queryParams.addAll(_queryParams('', 'isEncoded', isEncoded)); + } + if (isFavorite != null) { + queryParams.addAll(_queryParams('', 'isFavorite', isFavorite)); + } + if (isMotion != null) { + queryParams.addAll(_queryParams('', 'isMotion', isMotion)); + } + if (isNotInAlbum != null) { + queryParams.addAll(_queryParams('', 'isNotInAlbum', isNotInAlbum)); + } + if (isOffline != null) { + queryParams.addAll(_queryParams('', 'isOffline', isOffline)); + } + if (lensModel != null) { + queryParams.addAll(_queryParams('', 'lensModel', lensModel)); + } + if (libraryId != null) { + queryParams.addAll(_queryParams('', 'libraryId', libraryId)); + } + if (make != null) { + queryParams.addAll(_queryParams('', 'make', make)); + } + if (minFileSize != null) { + queryParams.addAll(_queryParams('', 'minFileSize', minFileSize)); + } + if (model != null) { + queryParams.addAll(_queryParams('', 'model', model)); + } + if (personIds != null) { + queryParams.addAll(_queryParams('multi', 'personIds', personIds)); + } + if (rating != null) { + queryParams.addAll(_queryParams('', 'rating', rating)); + } + if (size != null) { + queryParams.addAll(_queryParams('', 'size', size)); + } + if (state != null) { + queryParams.addAll(_queryParams('', 'state', state)); + } + if (tagIds != null) { + queryParams.addAll(_queryParams('multi', 'tagIds', tagIds)); + } + if (takenAfter != null) { + queryParams.addAll(_queryParams('', 'takenAfter', takenAfter)); + } + if (takenBefore != null) { + queryParams.addAll(_queryParams('', 'takenBefore', takenBefore)); + } + if (trashedAfter != null) { + queryParams.addAll(_queryParams('', 'trashedAfter', trashedAfter)); + } + if (trashedBefore != null) { + queryParams.addAll(_queryParams('', 'trashedBefore', trashedBefore)); + } + if (type != null) { + queryParams.addAll(_queryParams('', 'type', type)); + } + if (updatedAfter != null) { + queryParams.addAll(_queryParams('', 'updatedAfter', updatedAfter)); + } + if (updatedBefore != null) { + queryParams.addAll(_queryParams('', 'updatedBefore', updatedBefore)); + } + if (visibility != null) { + queryParams.addAll(_queryParams('', 'visibility', visibility)); + } + if (withDeleted != null) { + queryParams.addAll(_queryParams('', 'withDeleted', withDeleted)); + } + if (withExif != null) { + queryParams.addAll(_queryParams('', 'withExif', withExif)); + } + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [List] albumIds: + /// + /// * [String] city: + /// + /// * [String] country: + /// + /// * [DateTime] createdAfter: + /// + /// * [DateTime] createdBefore: + /// + /// * [String] deviceId: + /// + /// * [bool] isEncoded: + /// + /// * [bool] isFavorite: + /// + /// * [bool] isMotion: + /// + /// * [bool] isNotInAlbum: + /// + /// * [bool] isOffline: + /// + /// * [String] lensModel: + /// + /// * [String] libraryId: + /// + /// * [String] make: + /// + /// * [int] minFileSize: + /// + /// * [String] model: + /// + /// * [List] personIds: + /// + /// * [num] rating: + /// + /// * [num] size: + /// + /// * [String] state: + /// + /// * [List] tagIds: + /// + /// * [DateTime] takenAfter: + /// + /// * [DateTime] takenBefore: + /// + /// * [DateTime] trashedAfter: + /// + /// * [DateTime] trashedBefore: + /// + /// * [AssetTypeEnum] type: + /// + /// * [DateTime] updatedAfter: + /// + /// * [DateTime] updatedBefore: + /// + /// * [AssetVisibility] visibility: + /// + /// * [bool] withDeleted: + /// + /// * [bool] withExif: + Future?> searchLargeAssets({ List? albumIds, String? city, String? country, DateTime? createdAfter, DateTime? createdBefore, String? deviceId, bool? isEncoded, bool? isFavorite, bool? isMotion, bool? isNotInAlbum, bool? isOffline, String? lensModel, String? libraryId, String? make, int? minFileSize, String? model, List? personIds, num? rating, num? size, String? state, List? tagIds, DateTime? takenAfter, DateTime? takenBefore, DateTime? trashedAfter, DateTime? trashedBefore, AssetTypeEnum? type, DateTime? updatedAfter, DateTime? updatedBefore, AssetVisibility? visibility, bool? withDeleted, bool? withExif, }) async { + final response = await searchLargeAssetsWithHttpInfo( 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, minFileSize: minFileSize, 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, ); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } + /// Performs an HTTP 'GET /search/person' operation and returns the [Response]. /// Parameters: /// diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 8b305aa8b5..b8089083d1 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -5307,6 +5307,323 @@ "x-immich-permission": "asset.read" } }, + "/search/large-assets": { + "post": { + "operationId": "searchLargeAssets", + "parameters": [ + { + "name": "albumIds", + "required": false, + "in": "query", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "city", + "required": false, + "in": "query", + "schema": { + "nullable": true, + "type": "string" + } + }, + { + "name": "country", + "required": false, + "in": "query", + "schema": { + "nullable": true, + "type": "string" + } + }, + { + "name": "createdAfter", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "name": "createdBefore", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "name": "deviceId", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "isEncoded", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isFavorite", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isMotion", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isNotInAlbum", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isOffline", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "lensModel", + "required": false, + "in": "query", + "schema": { + "nullable": true, + "type": "string" + } + }, + { + "name": "libraryId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "nullable": true, + "type": "string" + } + }, + { + "name": "make", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "minFileSize", + "required": false, + "in": "query", + "schema": { + "minimum": 0, + "type": "integer" + } + }, + { + "name": "model", + "required": false, + "in": "query", + "schema": { + "nullable": true, + "type": "string" + } + }, + { + "name": "personIds", + "required": false, + "in": "query", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "rating", + "required": false, + "in": "query", + "schema": { + "minimum": -1, + "maximum": 5, + "type": "number" + } + }, + { + "name": "size", + "required": false, + "in": "query", + "schema": { + "minimum": 1, + "maximum": 1000, + "type": "number" + } + }, + { + "name": "state", + "required": false, + "in": "query", + "schema": { + "nullable": true, + "type": "string" + } + }, + { + "name": "tagIds", + "required": false, + "in": "query", + "schema": { + "nullable": true, + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "takenAfter", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "name": "takenBefore", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "name": "trashedAfter", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "name": "trashedBefore", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "name": "type", + "required": false, + "in": "query", + "schema": { + "$ref": "#/components/schemas/AssetTypeEnum" + } + }, + { + "name": "updatedAfter", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "name": "updatedBefore", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "name": "visibility", + "required": false, + "in": "query", + "schema": { + "$ref": "#/components/schemas/AssetVisibility" + } + }, + { + "name": "withDeleted", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "withExif", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/AssetResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Search" + ] + } + }, "/search/metadata": { "post": { "operationId": "searchAssets", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 063fc86782..33f0da8a08 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -2954,6 +2954,79 @@ export function getExploreData(opts?: Oazapfts.RequestOpts) { ...opts })); } +export function searchLargeAssets({ albumIds, city, country, createdAfter, createdBefore, deviceId, isEncoded, isFavorite, isMotion, isNotInAlbum, isOffline, lensModel, libraryId, make, minFileSize, model, personIds, rating, size, state, tagIds, takenAfter, takenBefore, trashedAfter, trashedBefore, $type, updatedAfter, updatedBefore, visibility, withDeleted, withExif }: { + albumIds?: string[]; + city?: string | null; + country?: string | null; + createdAfter?: string; + createdBefore?: string; + deviceId?: string; + isEncoded?: boolean; + isFavorite?: boolean; + isMotion?: boolean; + isNotInAlbum?: boolean; + isOffline?: boolean; + lensModel?: string | null; + libraryId?: string | null; + make?: string; + minFileSize?: number; + model?: string | null; + personIds?: string[]; + rating?: number; + size?: number; + state?: string | null; + tagIds?: string[] | null; + takenAfter?: string; + takenBefore?: string; + trashedAfter?: string; + trashedBefore?: string; + $type?: AssetTypeEnum; + updatedAfter?: string; + updatedBefore?: string; + visibility?: AssetVisibility; + withDeleted?: boolean; + withExif?: boolean; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AssetResponseDto[]; + }>(`/search/large-assets${QS.query(QS.explode({ + albumIds, + city, + country, + createdAfter, + createdBefore, + deviceId, + isEncoded, + isFavorite, + isMotion, + isNotInAlbum, + isOffline, + lensModel, + libraryId, + make, + minFileSize, + model, + personIds, + rating, + size, + state, + tagIds, + takenAfter, + takenBefore, + trashedAfter, + trashedBefore, + "type": $type, + updatedAfter, + updatedBefore, + visibility, + withDeleted, + withExif + }))}`, { + ...opts, + method: "POST" + })); +} export function searchAssets({ metadataSearchDto }: { metadataSearchDto: MetadataSearchDto; }, opts?: Oazapfts.RequestOpts) { diff --git a/server/src/controllers/search.controller.ts b/server/src/controllers/search.controller.ts index fefa916fe7..15f8bc3a5a 100644 --- a/server/src/controllers/search.controller.ts +++ b/server/src/controllers/search.controller.ts @@ -4,6 +4,7 @@ import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { PersonResponseDto } from 'src/dtos/person.dto'; import { + LargeAssetSearchDto, MetadataSearchDto, PlacesResponseDto, RandomSearchDto, @@ -46,6 +47,13 @@ export class SearchController { return this.service.searchRandom(auth, dto); } + @Post('large-assets') + @HttpCode(HttpStatus.OK) + @Authenticated({ permission: Permission.AssetRead }) + searchLargeAssets(@Auth() auth: AuthDto, @Query() dto: LargeAssetSearchDto): Promise { + return this.service.searchLargeAssets(auth, dto); + } + @Post('smart') @HttpCode(HttpStatus.OK) @Authenticated({ permission: Permission.AssetRead }) diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index aef78e51ea..f709ad94ab 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -126,6 +126,15 @@ export class RandomSearchDto extends BaseSearchWithResultsDto { withPeople?: boolean; } +export class LargeAssetSearchDto extends BaseSearchWithResultsDto { + @Optional() + @IsInt() + @Min(0) + @Type(() => Number) + @ApiProperty({ type: 'integer' }) + minFileSize?: number; +} + export class MetadataSearchDto extends RandomSearchDto { @ValidateUUID({ optional: true }) id?: string; diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index ef5363126f..be2245a74e 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -77,6 +77,27 @@ union all limit $15 +-- SearchRepository.searchLargeAssets +select + "asset".*, + to_json("asset_exif") as "exifInfo" +from + "asset" + inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."visibility" = $1 + and "asset"."fileCreatedAt" >= $2 + and "asset_exif"."lensModel" = $3 + and "asset"."ownerId" = any ($4::uuid[]) + and "asset"."isFavorite" = $5 + and "asset"."deletedAt" is null + and "asset_exif"."fileSizeInByte" > $6 +order by + "asset_exif"."fileSizeInByte" desc +limit + $7 + -- SearchRepository.searchSmart begin set diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 61e0cc1e29..36ef7a27f1 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -8,7 +8,7 @@ import { AssetStatus, AssetType, AssetVisibility, VectorIndex } from 'src/enum'; import { probes } from 'src/repositories/database.repository'; import { DB } from 'src/schema'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; -import { anyUuid, searchAssetBuilder } from 'src/utils/database'; +import { anyUuid, searchAssetBuilder, withExif } from 'src/utils/database'; import { paginationHelper } from 'src/utils/pagination'; import { isValidInteger } from 'src/validation'; @@ -129,6 +129,8 @@ export type SmartSearchOptions = SearchDateOptions & SearchPeopleOptions & SearchTagOptions; +export type LargeAssetSearchOptions = AssetSearchOptions & { minFileSize?: number }; + export interface FaceEmbeddingSearch extends SearchEmbeddingOptions { hasPerson?: boolean; numResults: number; @@ -237,6 +239,29 @@ export class SearchRepository { return rows; } + @GenerateSql({ + params: [ + 100, + { + takenAfter: DummyValue.DATE, + lensModel: DummyValue.STRING, + withStacked: true, + isFavorite: true, + userIds: [DummyValue.UUID], + }, + ], + }) + searchLargeAssets(size: number, options: LargeAssetSearchOptions) { + const orderDirection = (options.orderDirection?.toLowerCase() || 'desc') as OrderByDirection; + return searchAssetBuilder(this.db, options) + .selectAll('asset') + .$call(withExif) + .where('asset_exif.fileSizeInByte', '>', options.minFileSize || 0) + .orderBy('asset_exif.fileSizeInByte', orderDirection) + .limit(size) + .execute(); + } + @GenerateSql({ params: [ { page: 1, size: 200 }, diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index 1c75c4a434..b9391fed90 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -4,6 +4,7 @@ import { AssetMapOptions, AssetResponseDto, MapAsset, mapAsset } from 'src/dtos/ import { AuthDto } from 'src/dtos/auth.dto'; import { mapPerson, PersonResponseDto } from 'src/dtos/person.dto'; import { + LargeAssetSearchDto, mapPlaces, MetadataSearchDto, PlacesResponseDto, @@ -91,6 +92,16 @@ export class SearchService extends BaseService { return items.map((item) => mapAsset(item, { auth })); } + async searchLargeAssets(auth: AuthDto, dto: LargeAssetSearchDto): Promise { + if (dto.visibility === AssetVisibility.Locked) { + requireElevatedPermission(auth); + } + + const userIds = await this.getUserIdsToSearch(auth); + const items = await this.searchRepository.searchLargeAssets(dto.size || 250, { ...dto, userIds }); + return items.map((item) => mapAsset(item, { auth })); + } + async searchSmart(auth: AuthDto, dto: SmartSearchDto): Promise { if (dto.visibility === AssetVisibility.Locked) { requireElevatedPermission(auth); diff --git a/server/test/medium/specs/services/search.service.spec.ts b/server/test/medium/specs/services/search.service.spec.ts new file mode 100644 index 0000000000..517e6cc277 --- /dev/null +++ b/server/test/medium/specs/services/search.service.spec.ts @@ -0,0 +1,55 @@ +import { Kysely } from 'kysely'; +import { AccessRepository } from 'src/repositories/access.repository'; +import { DatabaseRepository } from 'src/repositories/database.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { PartnerRepository } from 'src/repositories/partner.repository'; +import { PersonRepository } from 'src/repositories/person.repository'; +import { SearchRepository } from 'src/repositories/search.repository'; +import { DB } from 'src/schema'; +import { SearchService } from 'src/services/search.service'; +import { newMediumService } from 'test/medium.factory'; +import { factory } from 'test/small.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = (db?: Kysely) => { + return newMediumService(SearchService, { + database: db || defaultDatabase, + real: [AccessRepository, DatabaseRepository, SearchRepository, PartnerRepository, PersonRepository], + mock: [LoggingRepository], + }); +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(SearchService.name, () => { + it('should work', () => { + const { sut } = setup(); + expect(sut).toBeDefined(); + }); + + it('should return assets', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + + const assets = []; + const sizes = [12_334, 599, 123_456]; + + for (let i = 0; i < sizes.length; i++) { + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, fileSizeInByte: sizes[i] }); + assets.push(asset); + } + + const auth = factory.auth({ user: { id: user.id } }); + + await expect(sut.searchLargeAssets(auth, {})).resolves.toEqual([ + expect.objectContaining({ id: assets[2].id }), + expect.objectContaining({ id: assets[0].id }), + expect.objectContaining({ id: assets[1].id }), + ]); + }); +}); diff --git a/web/src/lib/components/utilities-page/large-assets/large-asset-data.svelte b/web/src/lib/components/utilities-page/large-assets/large-asset-data.svelte new file mode 100644 index 0000000000..71f3dbb5c4 --- /dev/null +++ b/web/src/lib/components/utilities-page/large-assets/large-asset-data.svelte @@ -0,0 +1,58 @@ + + +
+
+ +
+ +
+
+
{asset.originalFileName}
+ {getAssetResolution(asset)} +
+
+ {getFileSize(asset, 1)} +
+
+
diff --git a/web/src/lib/components/utilities-page/utilities-menu.svelte b/web/src/lib/components/utilities-page/utilities-menu.svelte index 7deddc6bee..5484ce4ea0 100644 --- a/web/src/lib/components/utilities-page/utilities-menu.svelte +++ b/web/src/lib/components/utilities-page/utilities-menu.svelte @@ -1,7 +1,7 @@ @@ -17,4 +17,13 @@ {$t('review_duplicates')} + + + + {$t('review_large_files')} + diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index b354989e17..f2de6d5deb 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -51,6 +51,7 @@ export enum AppRoute { UTILITIES = '/utilities', DUPLICATES = '/utilities/duplicates', + LARGE_FILES = '/utilities/large-files', FOLDERS = '/folders', TAGS = '/tags', diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index d3feff8302..7267520549 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -275,9 +275,9 @@ export function isFlipped(orientation?: string | null) { return value && (isRotated270CW(value) || isRotated90CW(value)); } -export function getFileSize(asset: AssetResponseDto): string { +export function getFileSize(asset: AssetResponseDto, maxPrecision = 4): string { const size = asset.exifInfo?.fileSizeInByte || 0; - return size > 0 ? getByteUnitString(size, undefined, 4) : 'Invalid Data'; + return size > 0 ? getByteUnitString(size, undefined, maxPrecision) : 'Invalid Data'; } export function getAssetResolution(asset: AssetResponseDto): string { diff --git a/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/+page.svelte new file mode 100644 index 0000000000..75ac4fab93 --- /dev/null +++ b/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -0,0 +1,89 @@ + + + +
+ {#if assets && data.assets.length > 0} + {#each assets as asset (asset.id)} + setAsset(asset)} /> + {/each} + {:else} +

+ {$t('no_assets_to_show')} +

+ {/if} +
+
+ +{#if $showAssetViewer} + {#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} + + 1} + {onNext} + {onPrevious} + {onRandom} + {onAction} + onClose={() => { + assetViewingStore.showAssetViewer(false); + handlePromiseError(navigate({ targetRoute: 'current', assetId: null })); + }} + /> + + {/await} +{/if} diff --git a/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/+page.ts new file mode 100644 index 0000000000..6780fdb023 --- /dev/null +++ b/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -0,0 +1,17 @@ +import { authenticate } from '$lib/utils/auth'; +import { getFormatter } from '$lib/utils/i18n'; +import { searchLargeAssets } from '@immich/sdk'; +import type { PageLoad } from './$types'; + +export const load = (async ({ url }) => { + await authenticate(url); + const assets = await searchLargeAssets({ minFileSize: 0 }); + const $t = await getFormatter(); + + return { + assets, + meta: { + title: $t('large_files'), + }, + }; +}) satisfies PageLoad;