feat(server): asset search endpoint (#4931)

* feat(server): GET /assets endpoint

* chore: open api

* chore: use dumb name

* feat: search by make, model, lens, city, state, country

* chore: open api

* chore: pagination validation and tests

* chore: pr feedback
This commit is contained in:
Jason Rasmussen
2023-11-14 17:47:15 -05:00
committed by GitHub
parent 7a8f8e5472
commit 753dab8b3c
24 changed files with 3151 additions and 94 deletions
+624
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -29,6 +29,7 @@ doc/AssetIdsDto.md
doc/AssetIdsResponseDto.md
doc/AssetJobName.md
doc/AssetJobsDto.md
doc/AssetOrder.md
doc/AssetResponseDto.md
doc/AssetStatsResponseDto.md
doc/AssetTypeEnum.md
@@ -220,6 +221,7 @@ lib/model/asset_ids_dto.dart
lib/model/asset_ids_response_dto.dart
lib/model/asset_job_name.dart
lib/model/asset_jobs_dto.dart
lib/model/asset_order.dart
lib/model/asset_response_dto.dart
lib/model/asset_stats_response_dto.dart
lib/model/asset_type_enum.dart
@@ -376,6 +378,7 @@ test/asset_ids_dto_test.dart
test/asset_ids_response_dto_test.dart
test/asset_job_name_test.dart
test/asset_jobs_dto_test.dart
test/asset_order_test.dart
test/asset_response_dto_test.dart
test/asset_stats_response_dto_test.dart
test/asset_type_enum_test.dart
+2
View File
@@ -116,6 +116,7 @@ Class | Method | HTTP request | Description
*AssetApi* | [**restoreTrash**](doc//AssetApi.md#restoretrash) | **POST** /asset/trash/restore |
*AssetApi* | [**runAssetJobs**](doc//AssetApi.md#runassetjobs) | **POST** /asset/jobs |
*AssetApi* | [**searchAsset**](doc//AssetApi.md#searchasset) | **POST** /asset/search |
*AssetApi* | [**searchAssets**](doc//AssetApi.md#searchassets) | **GET** /assets |
*AssetApi* | [**serveFile**](doc//AssetApi.md#servefile) | **GET** /asset/file/{id} |
*AssetApi* | [**updateAsset**](doc//AssetApi.md#updateasset) | **PUT** /asset/{id} |
*AssetApi* | [**updateAssets**](doc//AssetApi.md#updateassets) | **PUT** /asset |
@@ -229,6 +230,7 @@ Class | Method | HTTP request | Description
- [AssetIdsResponseDto](doc//AssetIdsResponseDto.md)
- [AssetJobName](doc//AssetJobName.md)
- [AssetJobsDto](doc//AssetJobsDto.md)
- [AssetOrder](doc//AssetOrder.md)
- [AssetResponseDto](doc//AssetResponseDto.md)
- [AssetStatsResponseDto](doc//AssetStatsResponseDto.md)
- [AssetTypeEnum](doc//AssetTypeEnum.md)
+134
View File
@@ -34,6 +34,7 @@ Method | HTTP request | Description
[**restoreTrash**](AssetApi.md#restoretrash) | **POST** /asset/trash/restore |
[**runAssetJobs**](AssetApi.md#runassetjobs) | **POST** /asset/jobs |
[**searchAsset**](AssetApi.md#searchasset) | **POST** /asset/search |
[**searchAssets**](AssetApi.md#searchassets) | **GET** /assets |
[**serveFile**](AssetApi.md#servefile) | **GET** /asset/file/{id} |
[**updateAsset**](AssetApi.md#updateasset) | **PUT** /asset/{id} |
[**updateAssets**](AssetApi.md#updateassets) | **PUT** /asset |
@@ -1477,6 +1478,139 @@ Name | Type | Description | Notes
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **searchAssets**
> List<AssetResponseDto> searchAssets(id, libraryId, type, order, deviceAssetId, deviceId, checksum, isArchived, isEncoded, isExternal, isFavorite, isMotion, isOffline, isReadOnly, isVisible, withDeleted, withStacked, withExif, withPeople, createdBefore, createdAfter, updatedBefore, updatedAfter, trashedBefore, trashedAfter, takenBefore, takenAfter, originalFileName, originalPath, resizePath, webpPath, encodedVideoPath, city, state, country, make, model, lensModel, page, size)
### Example
```dart
import 'package:openapi/api.dart';
// TODO Configure API key authorization: cookie
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
// TODO Configure API key authorization: api_key
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = AssetApi();
final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final libraryId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final type = ; // AssetTypeEnum |
final order = ; // AssetOrder |
final deviceAssetId = deviceAssetId_example; // String |
final deviceId = deviceId_example; // String |
final checksum = checksum_example; // String |
final isArchived = true; // bool |
final isEncoded = true; // bool |
final isExternal = true; // bool |
final isFavorite = true; // bool |
final isMotion = true; // bool |
final isOffline = true; // bool |
final isReadOnly = true; // bool |
final isVisible = true; // bool |
final withDeleted = true; // bool |
final withStacked = true; // bool |
final withExif = true; // bool |
final withPeople = true; // bool |
final createdBefore = 2013-10-20T19:20:30+01:00; // DateTime |
final createdAfter = 2013-10-20T19:20:30+01:00; // DateTime |
final updatedBefore = 2013-10-20T19:20:30+01:00; // DateTime |
final updatedAfter = 2013-10-20T19:20:30+01:00; // DateTime |
final trashedBefore = 2013-10-20T19:20:30+01:00; // DateTime |
final trashedAfter = 2013-10-20T19:20:30+01:00; // DateTime |
final takenBefore = 2013-10-20T19:20:30+01:00; // DateTime |
final takenAfter = 2013-10-20T19:20:30+01:00; // DateTime |
final originalFileName = originalFileName_example; // String |
final originalPath = originalPath_example; // String |
final resizePath = resizePath_example; // String |
final webpPath = webpPath_example; // String |
final encodedVideoPath = encodedVideoPath_example; // String |
final city = city_example; // String |
final state = state_example; // String |
final country = country_example; // String |
final make = make_example; // String |
final model = model_example; // String |
final lensModel = lensModel_example; // String |
final page = 8.14; // num |
final size = 8.14; // num |
try {
final result = api_instance.searchAssets(id, libraryId, type, order, deviceAssetId, deviceId, checksum, isArchived, isEncoded, isExternal, isFavorite, isMotion, isOffline, isReadOnly, isVisible, withDeleted, withStacked, withExif, withPeople, createdBefore, createdAfter, updatedBefore, updatedAfter, trashedBefore, trashedAfter, takenBefore, takenAfter, originalFileName, originalPath, resizePath, webpPath, encodedVideoPath, city, state, country, make, model, lensModel, page, size);
print(result);
} catch (e) {
print('Exception when calling AssetApi->searchAssets: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**id** | **String**| | [optional]
**libraryId** | **String**| | [optional]
**type** | [**AssetTypeEnum**](.md)| | [optional]
**order** | [**AssetOrder**](.md)| | [optional]
**deviceAssetId** | **String**| | [optional]
**deviceId** | **String**| | [optional]
**checksum** | **String**| | [optional]
**isArchived** | **bool**| | [optional]
**isEncoded** | **bool**| | [optional]
**isExternal** | **bool**| | [optional]
**isFavorite** | **bool**| | [optional]
**isMotion** | **bool**| | [optional]
**isOffline** | **bool**| | [optional]
**isReadOnly** | **bool**| | [optional]
**isVisible** | **bool**| | [optional]
**withDeleted** | **bool**| | [optional]
**withStacked** | **bool**| | [optional]
**withExif** | **bool**| | [optional]
**withPeople** | **bool**| | [optional]
**createdBefore** | **DateTime**| | [optional]
**createdAfter** | **DateTime**| | [optional]
**updatedBefore** | **DateTime**| | [optional]
**updatedAfter** | **DateTime**| | [optional]
**trashedBefore** | **DateTime**| | [optional]
**trashedAfter** | **DateTime**| | [optional]
**takenBefore** | **DateTime**| | [optional]
**takenAfter** | **DateTime**| | [optional]
**originalFileName** | **String**| | [optional]
**originalPath** | **String**| | [optional]
**resizePath** | **String**| | [optional]
**webpPath** | **String**| | [optional]
**encodedVideoPath** | **String**| | [optional]
**city** | **String**| | [optional]
**state** | **String**| | [optional]
**country** | **String**| | [optional]
**make** | **String**| | [optional]
**model** | **String**| | [optional]
**lensModel** | **String**| | [optional]
**page** | **num**| | [optional]
**size** | **num**| | [optional]
### Return type
[**List<AssetResponseDto>**](AssetResponseDto.md)
### Authorization
[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **serveFile**
> MultipartFile serveFile(id, isThumb, isWeb, key)
+14
View File
@@ -0,0 +1,14 @@
# openapi.model.AssetOrder
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+1
View File
@@ -68,6 +68,7 @@ part 'model/asset_ids_dto.dart';
part 'model/asset_ids_response_dto.dart';
part 'model/asset_job_name.dart';
part 'model/asset_jobs_dto.dart';
part 'model/asset_order.dart';
part 'model/asset_response_dto.dart';
part 'model/asset_stats_response_dto.dart';
part 'model/asset_type_enum.dart';
File diff suppressed because it is too large Load Diff
+2
View File
@@ -225,6 +225,8 @@ class ApiClient {
return AssetJobNameTypeTransformer().decode(value);
case 'AssetJobsDto':
return AssetJobsDto.fromJson(value);
case 'AssetOrder':
return AssetOrderTypeTransformer().decode(value);
case 'AssetResponseDto':
return AssetResponseDto.fromJson(value);
case 'AssetStatsResponseDto':
+3
View File
@@ -58,6 +58,9 @@ String parameterToString(dynamic value) {
if (value is AssetJobName) {
return AssetJobNameTypeTransformer().encode(value).toString();
}
if (value is AssetOrder) {
return AssetOrderTypeTransformer().encode(value).toString();
}
if (value is AssetTypeEnum) {
return AssetTypeEnumTypeTransformer().encode(value).toString();
}
+85
View File
@@ -0,0 +1,85 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AssetOrder {
/// Instantiate a new enum with the provided [value].
const AssetOrder._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const asc = AssetOrder._(r'asc');
static const desc = AssetOrder._(r'desc');
/// List of all possible values in this [enum][AssetOrder].
static const values = <AssetOrder>[
asc,
desc,
];
static AssetOrder? fromJson(dynamic value) => AssetOrderTypeTransformer().decode(value);
static List<AssetOrder>? listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetOrder>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AssetOrder.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [AssetOrder] to String,
/// and [decode] dynamic data back to [AssetOrder].
class AssetOrderTypeTransformer {
factory AssetOrderTypeTransformer() => _instance ??= const AssetOrderTypeTransformer._();
const AssetOrderTypeTransformer._();
String encode(AssetOrder data) => data.value;
/// Decodes a [dynamic value][data] to a AssetOrder.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
AssetOrder? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'asc': return AssetOrder.asc;
case r'desc': return AssetOrder.desc;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [AssetOrderTypeTransformer] instance.
static AssetOrderTypeTransformer? _instance;
}
+5
View File
@@ -152,6 +152,11 @@ void main() {
// TODO
});
//Future<List<AssetResponseDto>> searchAssets({ String id, String libraryId, AssetTypeEnum type, AssetOrder order, String deviceAssetId, String deviceId, String checksum, bool isArchived, bool isEncoded, bool isExternal, bool isFavorite, bool isMotion, bool isOffline, bool isReadOnly, bool isVisible, bool withDeleted, bool withStacked, bool withExif, bool withPeople, DateTime createdBefore, DateTime createdAfter, DateTime updatedBefore, DateTime updatedAfter, DateTime trashedBefore, DateTime trashedAfter, DateTime takenBefore, DateTime takenAfter, String originalFileName, String originalPath, String resizePath, String webpPath, String encodedVideoPath, String city, String state, String country, String make, String model, String lensModel, num page, num size }) async
test('test searchAssets', () async {
// TODO
});
//Future<MultipartFile> serveFile(String id, { bool isThumb, bool isWeb, String key }) async
test('test serveFile', () async {
// TODO
+21
View File
@@ -0,0 +1,21 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for AssetOrder
void main() {
group('test AssetOrder', () {
});
}
File diff suppressed because it is too large Load Diff
+30
View File
@@ -30,6 +30,8 @@ import {
AssetIdsDto,
AssetJobName,
AssetJobsDto,
AssetOrder,
AssetSearchDto,
AssetStatsDto,
DownloadArchiveInfo,
DownloadInfoDto,
@@ -91,6 +93,34 @@ export class AssetService {
this.configCore = SystemConfigCore.create(configRepository);
}
search(authUser: AuthUserDto, dto: AssetSearchDto) {
let checksum: Buffer | undefined = undefined;
if (dto.checksum) {
const encoding = dto.checksum.length === 28 ? 'base64' : 'hex';
checksum = Buffer.from(dto.checksum, encoding);
}
const enumToOrder = { [AssetOrder.ASC]: 'ASC', [AssetOrder.DESC]: 'DESC' } as const;
const order = dto.order ? enumToOrder[dto.order] : undefined;
return this.assetRepository
.search({
...dto,
order,
checksum,
ownerId: authUser.id,
})
.then((assets) =>
assets.map((asset) =>
mapAsset(asset, {
stripMetadata: false,
withStack: true,
}),
),
);
}
canUploadFile({ authUser, fieldName, file }: UploadRequest): true {
this.access.requireUploadAccess(authUser);
+155 -2
View File
@@ -1,8 +1,161 @@
import { AssetType } from '@app/infra/entities';
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsBoolean, IsInt, IsPositive, IsString } from 'class-validator';
import { Optional, ValidateUUID } from '../../domain.util';
import { IsBoolean, IsEnum, IsInt, IsPositive, IsString, Min } from 'class-validator';
import { Optional, QueryBoolean, QueryDate, ValidateUUID } from '../../domain.util';
import { BulkIdsDto } from '../response-dto';
export enum AssetOrder {
ASC = 'asc',
DESC = 'desc',
}
export class AssetSearchDto {
@ValidateUUID({ optional: true })
id?: string;
@ValidateUUID({ optional: true })
libraryId?: string;
@IsString()
@Optional()
deviceAssetId?: string;
@IsString()
@Optional()
deviceId?: string;
@IsEnum(AssetType)
@Optional()
@ApiProperty({ enumName: 'AssetTypeEnum', enum: AssetType })
type?: AssetType;
@IsString()
@Optional()
checksum?: string;
@QueryBoolean({ optional: true })
isArchived?: boolean;
@QueryBoolean({ optional: true })
isEncoded?: boolean;
@QueryBoolean({ optional: true })
isExternal?: boolean;
@QueryBoolean({ optional: true })
isFavorite?: boolean;
@QueryBoolean({ optional: true })
isMotion?: boolean;
@QueryBoolean({ optional: true })
isOffline?: boolean;
@QueryBoolean({ optional: true })
isReadOnly?: boolean;
@QueryBoolean({ optional: true })
isVisible?: boolean;
@QueryBoolean({ optional: true })
withDeleted?: boolean;
@QueryBoolean({ optional: true })
withStacked?: boolean;
@QueryBoolean({ optional: true })
withExif?: boolean;
@QueryBoolean({ optional: true })
withPeople?: boolean;
@QueryDate({ optional: true })
createdBefore?: Date;
@QueryDate({ optional: true })
createdAfter?: Date;
@QueryDate({ optional: true })
updatedBefore?: Date;
@QueryDate({ optional: true })
updatedAfter?: Date;
@QueryDate({ optional: true })
trashedBefore?: Date;
@QueryDate({ optional: true })
trashedAfter?: Date;
@QueryDate({ optional: true })
takenBefore?: Date;
@QueryDate({ optional: true })
takenAfter?: Date;
@IsString()
@Optional()
originalFileName?: string;
@IsString()
@Optional()
originalPath?: string;
@IsString()
@Optional()
resizePath?: string;
@IsString()
@Optional()
webpPath?: string;
@IsString()
@Optional()
encodedVideoPath?: string;
@IsString()
@Optional()
city?: string;
@IsString()
@Optional()
state?: string;
@IsString()
@Optional()
country?: string;
@IsString()
@Optional()
make?: string;
@IsString()
@Optional()
model?: string;
@IsString()
@Optional()
lensModel?: string;
@IsEnum(AssetOrder)
@Optional()
@ApiProperty({ enumName: 'AssetOrder', enum: AssetOrder })
order?: AssetOrder;
@IsInt()
@Min(1)
@Type(() => Number)
@Optional()
page?: number;
@IsInt()
@Min(1)
@Type(() => Number)
@Optional()
size?: number;
}
export class AssetBulkUpdateDto extends BulkIdsDto {
@Optional()
@IsBoolean()
+28 -1
View File
@@ -1,6 +1,17 @@
import { applyDecorators } from '@nestjs/common';
import { ApiProperty } from '@nestjs/swagger';
import { IsArray, IsNotEmpty, IsOptional, IsString, IsUUID, ValidateIf, ValidationOptions } from 'class-validator';
import { Transform, Type } from 'class-transformer';
import {
IsArray,
IsBoolean,
IsDate,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
ValidateIf,
ValidationOptions,
} from 'class-validator';
import { CronJob } from 'cron';
import { basename, extname } from 'node:path';
import sanitize from 'sanitize-filename';
@@ -33,6 +44,22 @@ interface IValue {
value?: string;
}
export const QueryBoolean = ({ optional }: { optional?: boolean }) => {
const decorators = [IsBoolean(), Transform(toBoolean)];
if (optional) {
decorators.push(Optional());
}
return applyDecorators(...decorators);
};
export const QueryDate = ({ optional }: { optional?: boolean }) => {
const decorators = [IsDate(), Type(() => Date)];
if (optional) {
decorators.push(Optional());
}
return applyDecorators(...decorators);
};
export const toBoolean = ({ value }: IValue) => {
if (value == 'true') {
return true;
@@ -11,11 +11,58 @@ export interface AssetStatsOptions {
}
export interface AssetSearchOptions {
isVisible?: boolean;
trashedBefore?: Date;
id?: string;
libraryId?: string;
deviceAssetId?: string;
deviceId?: string;
ownerId?: string;
type?: AssetType;
order?: 'ASC' | 'DESC';
checksum?: Buffer;
isArchived?: boolean;
isEncoded?: boolean;
isExternal?: boolean;
isFavorite?: boolean;
isMotion?: boolean;
isOffline?: boolean;
isReadOnly?: boolean;
isVisible?: boolean;
withDeleted?: boolean;
withStacked?: boolean;
withExif?: boolean;
withPeople?: boolean;
createdBefore?: Date;
createdAfter?: Date;
updatedBefore?: Date;
updatedAfter?: Date;
trashedBefore?: Date;
trashedAfter?: Date;
takenBefore?: Date;
takenAfter?: Date;
originalFileName?: string;
originalPath?: string;
resizePath?: string;
webpPath?: string;
encodedVideoPath?: string;
city?: string;
state?: string;
country?: string;
make?: string;
model?: string;
lensModel?: string;
/** defaults to 'DESC' */
order?: 'ASC' | 'DESC';
/** defaults to 1 */
page?: number;
/** defaults to 250 */
size?: number;
}
export interface LivePhotoSearchOptions {
@@ -127,4 +174,5 @@ export interface IAssetRepository {
getTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise<AssetEntity[]>;
upsertExif(exif: Partial<ExifEntity>): Promise<void>;
upsertJobStatus(jobStatus: Partial<AssetJobStatusEntity>): Promise<void>;
search(options: AssetSearchOptions): Promise<AssetEntity[]>;
}
+2
View File
@@ -16,6 +16,7 @@ import {
AlbumController,
AppController,
AssetController,
AssetsController,
AuditController,
AuthController,
JobController,
@@ -41,6 +42,7 @@ import { ErrorInterceptor, FileUploadInterceptor } from './interceptors';
],
controllers: [
ActivityController,
AssetsController,
AssetController,
AssetControllerV1,
AppController,
@@ -4,6 +4,7 @@ import {
AssetIdsDto,
AssetJobsDto,
AssetResponseDto,
AssetSearchDto,
AssetService,
AssetStatsDto,
AssetStatsResponseDto,
@@ -42,6 +43,19 @@ import { UseValidation, asStreamableFile } from '../app.utils';
import { Route } from '../interceptors';
import { UUIDParamDto } from './dto/uuid-param.dto';
@ApiTags('Asset')
@Controller('assets')
@Authenticated()
@UseValidation()
export class AssetsController {
constructor(private service: AssetService) {}
@Get()
searchAssets(@AuthUser() authUser: AuthUserDto, @Query() dto: AssetSearchDto): Promise<AssetResponseDto[]> {
return this.service.search(authUser, dto);
}
}
@ApiTags('Asset')
@Controller(Route.ASSET)
@Authenticated()
@@ -18,12 +18,15 @@ import {
} from '@app/domain';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import _ from 'lodash';
import { DateTime } from 'luxon';
import { And, FindOptionsRelations, FindOptionsWhere, In, IsNull, LessThan, Not, Repository } from 'typeorm';
import { AssetEntity, AssetJobStatusEntity, AssetType, ExifEntity } from '../entities';
import OptionalBetween from '../utils/optional-between.util';
import { paginate } from '../utils/pagination.util';
const DEFAULT_SEARCH_SIZE = 250;
const truncateMap: Record<TimeBucketSize, string> = {
[TimeBucketSize.DAY]: 'day',
[TimeBucketSize.MONTH]: 'month',
@@ -50,6 +53,134 @@ export class AssetRepository implements IAssetRepository {
await this.jobStatusRepository.upsert(jobStatus, { conflictPaths: ['assetId'] });
}
search(options: AssetSearchOptions): Promise<AssetEntity[]> {
const {
id,
libraryId,
deviceAssetId,
type,
checksum,
ownerId,
isVisible,
isFavorite,
isExternal,
isReadOnly,
isOffline,
isArchived,
isMotion,
isEncoded,
createdBefore,
createdAfter,
updatedBefore,
updatedAfter,
trashedBefore,
trashedAfter,
takenBefore,
takenAfter,
originalFileName,
originalPath,
resizePath,
webpPath,
encodedVideoPath,
city,
state,
country,
make,
model,
lensModel,
withDeleted: _withDeleted,
withExif: _withExif,
withStacked,
withPeople,
order,
} = options;
const withDeleted = _withDeleted ?? (trashedAfter !== undefined || trashedBefore !== undefined);
const page = Math.max(options.page || 1, 1);
const size = Math.min(options.size || DEFAULT_SEARCH_SIZE, DEFAULT_SEARCH_SIZE);
const exifWhere = _.omitBy(
{
city,
state,
country,
make,
model,
lensModel,
},
_.isUndefined,
);
const withExif = Object.keys(exifWhere).length > 0 || _withExif;
const where = _.omitBy(
{
ownerId,
id,
libraryId,
deviceAssetId,
type,
checksum,
isVisible,
isFavorite,
isExternal,
isReadOnly,
isOffline,
isArchived,
livePhotoVideoId: isMotion && Not(IsNull()),
originalFileName,
originalPath,
resizePath,
webpPath,
encodedVideoPath: encodedVideoPath ?? (isEncoded && Not(IsNull())),
createdAt: OptionalBetween(createdAfter, createdBefore),
updatedAt: OptionalBetween(updatedAfter, updatedBefore),
deletedAt: OptionalBetween(trashedAfter, trashedBefore),
fileCreatedAt: OptionalBetween(takenAfter, takenBefore),
exifInfo: Object.keys(exifWhere).length > 0 ? exifWhere : undefined,
},
_.isUndefined,
);
const builder = this.repository.createQueryBuilder('asset');
if (withExif) {
if (_withExif) {
builder.leftJoinAndSelect('asset.exifInfo', 'exifInfo');
} else {
builder.leftJoin('asset.exifInfo', 'exifInfo');
}
}
if (withPeople) {
builder.leftJoinAndSelect('asset.faces', 'faces');
builder.leftJoinAndSelect('faces.person', 'person');
}
if (withStacked) {
builder.leftJoinAndSelect('asset.stack', 'stack');
}
if (withDeleted) {
builder.withDeleted();
}
builder
.where(where)
.skip(size * (page - 1))
.take(size)
.orderBy('asset.fileCreatedAt', order ?? 'DESC');
return builder.getMany();
}
create(asset: AssetCreate): Promise<AssetEntity> {
return this.repository.save(asset);
}
File diff suppressed because it is too large Load Diff
@@ -31,5 +31,6 @@ export const newAssetRepositoryMock = (): jest.Mocked<IAssetRepository> => {
getTimeBuckets: jest.fn(),
restoreAll: jest.fn(),
softDeleteAll: jest.fn(),
search: jest.fn(),
};
};
+19 -5
View File
@@ -5,25 +5,39 @@ import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import * as fs from 'fs';
import path from 'path';
import { EntityTarget, ObjectLiteral } from 'typeorm';
import { AppService } from '../src/microservices/app.service';
export const IMMICH_TEST_ASSET_PATH = process.env.IMMICH_TEST_ASSET_PATH;
export const IMMICH_TEST_ASSET_TEMP_PATH = path.normalize(`${IMMICH_TEST_ASSET_PATH}/temp/`);
export interface ResetOptions {
entities?: EntityTarget<ObjectLiteral>[];
}
export const db = {
reset: async () => {
reset: async (options?: ResetOptions) => {
if (!dataSource.isInitialized) {
await dataSource.initialize();
}
await dataSource.transaction(async (em) => {
for (const entity of dataSource.entityMetadatas) {
if (entity.tableName === 'users') {
const entities = options?.entities || [];
const tableNames =
entities.length > 0
? entities.map((entity) => em.getRepository(entity).metadata.tableName)
: dataSource.entityMetadatas.map((entity) => entity.tableName);
let deleteUsers = false;
for (const tableName of tableNames) {
if (tableName === 'users') {
deleteUsers = true;
continue;
}
await em.query(`DELETE FROM ${entity.tableName} CASCADE;`);
await em.query(`DELETE FROM ${tableName} CASCADE;`);
}
if (deleteUsers) {
await em.query(`DELETE FROM "users" CASCADE;`);
}
await em.query(`DELETE FROM "users" CASCADE;`);
});
},
disconnect: async () => {
+624
View File
File diff suppressed because it is too large Load Diff