diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index 90e96e3998..0ae5745a79 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -295,6 +295,12 @@ export interface AllJobStatusResponseDto { * @memberof AllJobStatusResponseDto */ 'clipEncoding': JobStatusDto; + /** + * + * @type {JobStatusDto} + * @memberof AllJobStatusResponseDto + */ + 'library': JobStatusDto; /** * * @type {JobStatusDto} @@ -621,12 +627,36 @@ export interface AssetResponseDto { * @memberof AssetResponseDto */ 'isArchived': boolean; + /** + * + * @type {boolean} + * @memberof AssetResponseDto + */ + 'isExternal': boolean; /** * * @type {boolean} * @memberof AssetResponseDto */ 'isFavorite': boolean; + /** + * + * @type {boolean} + * @memberof AssetResponseDto + */ + 'isOffline': boolean; + /** + * + * @type {boolean} + * @memberof AssetResponseDto + */ + 'isReadOnly': boolean; + /** + * + * @type {string} + * @memberof AssetResponseDto + */ + 'libraryId': string; /** * * @type {string} @@ -1097,6 +1127,45 @@ export interface CreateAlbumDto { */ 'sharedWithUserIds'?: Array; } +/** + * + * @export + * @interface CreateLibraryDto + */ +export interface CreateLibraryDto { + /** + * + * @type {Array} + * @memberof CreateLibraryDto + */ + 'exclusionPatterns'?: Array; + /** + * + * @type {Array} + * @memberof CreateLibraryDto + */ + 'importPaths'?: Array; + /** + * + * @type {boolean} + * @memberof CreateLibraryDto + */ + 'isVisible'?: boolean; + /** + * + * @type {string} + * @memberof CreateLibraryDto + */ + 'name'?: string; + /** + * + * @type {LibraryType} + * @memberof CreateLibraryDto + */ + 'type': LibraryType; +} + + /** * * @export @@ -1572,12 +1641,24 @@ export interface ImportAssetDto { * @memberof ImportAssetDto */ 'isArchived'?: boolean; + /** + * + * @type {boolean} + * @memberof ImportAssetDto + */ + 'isExternal'?: boolean; /** * * @type {boolean} * @memberof ImportAssetDto */ 'isFavorite': boolean; + /** + * + * @type {boolean} + * @memberof ImportAssetDto + */ + 'isOffline'?: boolean; /** * * @type {boolean} @@ -1590,6 +1671,12 @@ export interface ImportAssetDto { * @memberof ImportAssetDto */ 'isVisible'?: boolean; + /** + * + * @type {string} + * @memberof ImportAssetDto + */ + 'libraryId'?: string; /** * * @type {string} @@ -1693,7 +1780,8 @@ export const JobName = { BackgroundTask: 'backgroundTask', StorageTemplateMigration: 'storageTemplateMigration', Search: 'search', - Sidecar: 'sidecar' + Sidecar: 'sidecar', + Library: 'library' } as const; export type JobName = typeof JobName[keyof typeof JobName]; @@ -1731,6 +1819,120 @@ export interface JobStatusDto { */ 'queueStatus': QueueStatusDto; } +/** + * + * @export + * @interface LibraryResponseDto + */ +export interface LibraryResponseDto { + /** + * + * @type {number} + * @memberof LibraryResponseDto + */ + 'assetCount': number; + /** + * + * @type {string} + * @memberof LibraryResponseDto + */ + 'createdAt': string; + /** + * + * @type {Array} + * @memberof LibraryResponseDto + */ + 'exclusionPatterns': Array; + /** + * + * @type {string} + * @memberof LibraryResponseDto + */ + 'id': string; + /** + * + * @type {Array} + * @memberof LibraryResponseDto + */ + 'importPaths': Array; + /** + * + * @type {string} + * @memberof LibraryResponseDto + */ + 'name': string; + /** + * + * @type {string} + * @memberof LibraryResponseDto + */ + 'ownerId': string; + /** + * + * @type {string} + * @memberof LibraryResponseDto + */ + 'refreshedAt': string | null; + /** + * + * @type {LibraryType} + * @memberof LibraryResponseDto + */ + 'type': LibraryType; + /** + * + * @type {string} + * @memberof LibraryResponseDto + */ + 'updatedAt': string; +} + + +/** + * + * @export + * @interface LibraryStatsResponseDto + */ +export interface LibraryStatsResponseDto { + /** + * + * @type {number} + * @memberof LibraryStatsResponseDto + */ + 'photos': number; + /** + * + * @type {number} + * @memberof LibraryStatsResponseDto + */ + 'total': number; + /** + * + * @type {number} + * @memberof LibraryStatsResponseDto + */ + 'usage': number; + /** + * + * @type {number} + * @memberof LibraryStatsResponseDto + */ + 'videos': number; +} +/** + * + * @export + * @enum {string} + */ + +export const LibraryType = { + Upload: 'UPLOAD', + External: 'EXTERNAL' +} as const; + +export type LibraryType = typeof LibraryType[keyof typeof LibraryType]; + + /** * * @export @@ -2179,6 +2381,25 @@ export interface RecognitionConfig { } +/** + * + * @export + * @interface ScanLibraryDto + */ +export interface ScanLibraryDto { + /** + * + * @type {boolean} + * @memberof ScanLibraryDto + */ + 'refreshAllFiles'?: boolean; + /** + * + * @type {boolean} + * @memberof ScanLibraryDto + */ + 'refreshModifiedFiles'?: boolean; +} /** * * @export @@ -3007,6 +3228,12 @@ export interface SystemConfigJobDto { * @memberof SystemConfigJobDto */ 'clipEncoding': JobSettingsDto; + /** + * + * @type {JobSettingsDto} + * @memberof SystemConfigJobDto + */ + 'library': JobSettingsDto; /** * * @type {JobSettingsDto} @@ -3486,6 +3713,37 @@ export interface UpdateAssetDto { */ 'isFavorite'?: boolean; } +/** + * + * @export + * @interface UpdateLibraryDto + */ +export interface UpdateLibraryDto { + /** + * + * @type {Array} + * @memberof UpdateLibraryDto + */ + 'exclusionPatterns'?: Array; + /** + * + * @type {Array} + * @memberof UpdateLibraryDto + */ + 'importPaths'?: Array; + /** + * + * @type {boolean} + * @memberof UpdateLibraryDto + */ + 'isVisible'?: boolean; + /** + * + * @type {string} + * @memberof UpdateLibraryDto + */ + 'name'?: string; +} /** * * @export @@ -6463,14 +6721,17 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration * @param {string} [key] * @param {string} [duration] * @param {boolean} [isArchived] + * @param {boolean} [isExternal] + * @param {boolean} [isOffline] * @param {boolean} [isReadOnly] * @param {boolean} [isVisible] + * @param {string} [libraryId] * @param {File} [livePhotoData] * @param {File} [sidecarData] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - uploadFile: async (assetData: File, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, isFavorite: boolean, key?: string, duration?: string, isArchived?: boolean, isReadOnly?: boolean, isVisible?: boolean, livePhotoData?: File, sidecarData?: File, options: AxiosRequestConfig = {}): Promise => { + uploadFile: async (assetData: File, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, isFavorite: boolean, key?: string, duration?: string, isArchived?: boolean, isExternal?: boolean, isOffline?: boolean, isReadOnly?: boolean, isVisible?: boolean, libraryId?: string, livePhotoData?: File, sidecarData?: File, options: AxiosRequestConfig = {}): Promise => { // verify required parameter 'assetData' is not null or undefined assertParamExists('uploadFile', 'assetData', assetData) // verify required parameter 'deviceAssetId' is not null or undefined @@ -6538,10 +6799,18 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration localVarFormParams.append('isArchived', isArchived as any); } + if (isExternal !== undefined) { + localVarFormParams.append('isExternal', isExternal as any); + } + if (isFavorite !== undefined) { localVarFormParams.append('isFavorite', isFavorite as any); } + if (isOffline !== undefined) { + localVarFormParams.append('isOffline', isOffline as any); + } + if (isReadOnly !== undefined) { localVarFormParams.append('isReadOnly', isReadOnly as any); } @@ -6550,6 +6819,10 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration localVarFormParams.append('isVisible', isVisible as any); } + if (libraryId !== undefined) { + localVarFormParams.append('libraryId', libraryId as any); + } + if (livePhotoData !== undefined) { localVarFormParams.append('livePhotoData', livePhotoData as any); } @@ -6871,15 +7144,18 @@ export const AssetApiFp = function(configuration?: Configuration) { * @param {string} [key] * @param {string} [duration] * @param {boolean} [isArchived] + * @param {boolean} [isExternal] + * @param {boolean} [isOffline] * @param {boolean} [isReadOnly] * @param {boolean} [isVisible] + * @param {string} [libraryId] * @param {File} [livePhotoData] * @param {File} [sidecarData] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async uploadFile(assetData: File, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, isFavorite: boolean, key?: string, duration?: string, isArchived?: boolean, isReadOnly?: boolean, isVisible?: boolean, livePhotoData?: File, sidecarData?: File, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, key, duration, isArchived, isReadOnly, isVisible, livePhotoData, sidecarData, options); + async uploadFile(assetData: File, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, isFavorite: boolean, key?: string, duration?: string, isArchived?: boolean, isExternal?: boolean, isOffline?: boolean, isReadOnly?: boolean, isVisible?: boolean, libraryId?: string, livePhotoData?: File, sidecarData?: File, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, key, duration, isArchived, isExternal, isOffline, isReadOnly, isVisible, libraryId, livePhotoData, sidecarData, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, } @@ -7121,7 +7397,7 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath * @throws {RequiredError} */ uploadFile(requestParameters: AssetApiUploadFileRequest, options?: AxiosRequestConfig): AxiosPromise { - return localVarFp.uploadFile(requestParameters.assetData, requestParameters.deviceAssetId, requestParameters.deviceId, requestParameters.fileCreatedAt, requestParameters.fileModifiedAt, requestParameters.isFavorite, requestParameters.key, requestParameters.duration, requestParameters.isArchived, requestParameters.isReadOnly, requestParameters.isVisible, requestParameters.livePhotoData, requestParameters.sidecarData, options).then((request) => request(axios, basePath)); + return localVarFp.uploadFile(requestParameters.assetData, requestParameters.deviceAssetId, requestParameters.deviceId, requestParameters.fileCreatedAt, requestParameters.fileModifiedAt, requestParameters.isFavorite, requestParameters.key, requestParameters.duration, requestParameters.isArchived, requestParameters.isExternal, requestParameters.isOffline, requestParameters.isReadOnly, requestParameters.isVisible, requestParameters.libraryId, requestParameters.livePhotoData, requestParameters.sidecarData, options).then((request) => request(axios, basePath)); }, }; }; @@ -7727,6 +8003,20 @@ export interface AssetApiUploadFileRequest { */ readonly isArchived?: boolean + /** + * + * @type {boolean} + * @memberof AssetApiUploadFile + */ + readonly isExternal?: boolean + + /** + * + * @type {boolean} + * @memberof AssetApiUploadFile + */ + readonly isOffline?: boolean + /** * * @type {boolean} @@ -7741,6 +8031,13 @@ export interface AssetApiUploadFileRequest { */ readonly isVisible?: boolean + /** + * + * @type {string} + * @memberof AssetApiUploadFile + */ + readonly libraryId?: string + /** * * @type {File} @@ -8043,7 +8340,7 @@ export class AssetApi extends BaseAPI { * @memberof AssetApi */ public uploadFile(requestParameters: AssetApiUploadFileRequest, options?: AxiosRequestConfig) { - return AssetApiFp(this.configuration).uploadFile(requestParameters.assetData, requestParameters.deviceAssetId, requestParameters.deviceId, requestParameters.fileCreatedAt, requestParameters.fileModifiedAt, requestParameters.isFavorite, requestParameters.key, requestParameters.duration, requestParameters.isArchived, requestParameters.isReadOnly, requestParameters.isVisible, requestParameters.livePhotoData, requestParameters.sidecarData, options).then((request) => request(this.axios, this.basePath)); + return AssetApiFp(this.configuration).uploadFile(requestParameters.assetData, requestParameters.deviceAssetId, requestParameters.deviceId, requestParameters.fileCreatedAt, requestParameters.fileModifiedAt, requestParameters.isFavorite, requestParameters.key, requestParameters.duration, requestParameters.isArchived, requestParameters.isExternal, requestParameters.isOffline, requestParameters.isReadOnly, requestParameters.isVisible, requestParameters.libraryId, requestParameters.livePhotoData, requestParameters.sidecarData, options).then((request) => request(this.axios, this.basePath)); } } @@ -9038,6 +9335,741 @@ export class JobApi extends BaseAPI { } +/** + * LibraryApi - axios parameter creator + * @export + */ +export const LibraryApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * + * @param {CreateLibraryDto} createLibraryDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createLibrary: async (createLibraryDto: CreateLibraryDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'createLibraryDto' is not null or undefined + assertParamExists('createLibrary', 'createLibraryDto', createLibraryDto) + const localVarPath = `/library`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(createLibraryDto, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + deleteLibrary: async (id: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('deleteLibrary', 'id', id) + const localVarPath = `/library/{id}` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getAllForUser: async (options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/library`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getLibraryInfo: async (id: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('getLibraryInfo', 'id', id) + const localVarPath = `/library/{id}` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getLibraryStatistics: async (id: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('getLibraryStatistics', 'id', id) + const localVarPath = `/library/{id}/statistics` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + removeOfflineFiles: async (id: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('removeOfflineFiles', 'id', id) + const localVarPath = `/library/{id}/removeOffline` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} id + * @param {ScanLibraryDto} scanLibraryDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + scanLibrary: async (id: string, scanLibraryDto: ScanLibraryDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('scanLibrary', 'id', id) + // verify required parameter 'scanLibraryDto' is not null or undefined + assertParamExists('scanLibrary', 'scanLibraryDto', scanLibraryDto) + const localVarPath = `/library/{id}/scan` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(scanLibraryDto, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} id + * @param {UpdateLibraryDto} updateLibraryDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + updateLibrary: async (id: string, updateLibraryDto: UpdateLibraryDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('updateLibrary', 'id', id) + // verify required parameter 'updateLibraryDto' is not null or undefined + assertParamExists('updateLibrary', 'updateLibraryDto', updateLibraryDto) + const localVarPath = `/library/{id}` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(updateLibraryDto, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * LibraryApi - functional programming interface + * @export + */ +export const LibraryApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = LibraryApiAxiosParamCreator(configuration) + return { + /** + * + * @param {CreateLibraryDto} createLibraryDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async createLibrary(createLibraryDto: CreateLibraryDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.createLibrary(createLibraryDto, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async deleteLibrary(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.deleteLibrary(id, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getAllForUser(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getAllForUser(options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getLibraryInfo(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getLibraryInfo(id, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getLibraryStatistics(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getLibraryStatistics(id, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async removeOfflineFiles(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.removeOfflineFiles(id, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {string} id + * @param {ScanLibraryDto} scanLibraryDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async scanLibrary(id: string, scanLibraryDto: ScanLibraryDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.scanLibrary(id, scanLibraryDto, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {string} id + * @param {UpdateLibraryDto} updateLibraryDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async updateLibrary(id: string, updateLibraryDto: UpdateLibraryDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.updateLibrary(id, updateLibraryDto, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + } +}; + +/** + * LibraryApi - factory interface + * @export + */ +export const LibraryApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = LibraryApiFp(configuration) + return { + /** + * + * @param {LibraryApiCreateLibraryRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createLibrary(requestParameters: LibraryApiCreateLibraryRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.createLibrary(requestParameters.createLibraryDto, options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {LibraryApiDeleteLibraryRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + deleteLibrary(requestParameters: LibraryApiDeleteLibraryRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.deleteLibrary(requestParameters.id, options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getAllForUser(options?: AxiosRequestConfig): AxiosPromise> { + return localVarFp.getAllForUser(options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {LibraryApiGetLibraryInfoRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getLibraryInfo(requestParameters: LibraryApiGetLibraryInfoRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.getLibraryInfo(requestParameters.id, options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {LibraryApiGetLibraryStatisticsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getLibraryStatistics(requestParameters: LibraryApiGetLibraryStatisticsRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.getLibraryStatistics(requestParameters.id, options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {LibraryApiRemoveOfflineFilesRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + removeOfflineFiles(requestParameters: LibraryApiRemoveOfflineFilesRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.removeOfflineFiles(requestParameters.id, options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {LibraryApiScanLibraryRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + scanLibrary(requestParameters: LibraryApiScanLibraryRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.scanLibrary(requestParameters.id, requestParameters.scanLibraryDto, options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {LibraryApiUpdateLibraryRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + updateLibrary(requestParameters: LibraryApiUpdateLibraryRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.updateLibrary(requestParameters.id, requestParameters.updateLibraryDto, options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * Request parameters for createLibrary operation in LibraryApi. + * @export + * @interface LibraryApiCreateLibraryRequest + */ +export interface LibraryApiCreateLibraryRequest { + /** + * + * @type {CreateLibraryDto} + * @memberof LibraryApiCreateLibrary + */ + readonly createLibraryDto: CreateLibraryDto +} + +/** + * Request parameters for deleteLibrary operation in LibraryApi. + * @export + * @interface LibraryApiDeleteLibraryRequest + */ +export interface LibraryApiDeleteLibraryRequest { + /** + * + * @type {string} + * @memberof LibraryApiDeleteLibrary + */ + readonly id: string +} + +/** + * Request parameters for getLibraryInfo operation in LibraryApi. + * @export + * @interface LibraryApiGetLibraryInfoRequest + */ +export interface LibraryApiGetLibraryInfoRequest { + /** + * + * @type {string} + * @memberof LibraryApiGetLibraryInfo + */ + readonly id: string +} + +/** + * Request parameters for getLibraryStatistics operation in LibraryApi. + * @export + * @interface LibraryApiGetLibraryStatisticsRequest + */ +export interface LibraryApiGetLibraryStatisticsRequest { + /** + * + * @type {string} + * @memberof LibraryApiGetLibraryStatistics + */ + readonly id: string +} + +/** + * Request parameters for removeOfflineFiles operation in LibraryApi. + * @export + * @interface LibraryApiRemoveOfflineFilesRequest + */ +export interface LibraryApiRemoveOfflineFilesRequest { + /** + * + * @type {string} + * @memberof LibraryApiRemoveOfflineFiles + */ + readonly id: string +} + +/** + * Request parameters for scanLibrary operation in LibraryApi. + * @export + * @interface LibraryApiScanLibraryRequest + */ +export interface LibraryApiScanLibraryRequest { + /** + * + * @type {string} + * @memberof LibraryApiScanLibrary + */ + readonly id: string + + /** + * + * @type {ScanLibraryDto} + * @memberof LibraryApiScanLibrary + */ + readonly scanLibraryDto: ScanLibraryDto +} + +/** + * Request parameters for updateLibrary operation in LibraryApi. + * @export + * @interface LibraryApiUpdateLibraryRequest + */ +export interface LibraryApiUpdateLibraryRequest { + /** + * + * @type {string} + * @memberof LibraryApiUpdateLibrary + */ + readonly id: string + + /** + * + * @type {UpdateLibraryDto} + * @memberof LibraryApiUpdateLibrary + */ + readonly updateLibraryDto: UpdateLibraryDto +} + +/** + * LibraryApi - object-oriented interface + * @export + * @class LibraryApi + * @extends {BaseAPI} + */ +export class LibraryApi extends BaseAPI { + /** + * + * @param {LibraryApiCreateLibraryRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof LibraryApi + */ + public createLibrary(requestParameters: LibraryApiCreateLibraryRequest, options?: AxiosRequestConfig) { + return LibraryApiFp(this.configuration).createLibrary(requestParameters.createLibraryDto, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {LibraryApiDeleteLibraryRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof LibraryApi + */ + public deleteLibrary(requestParameters: LibraryApiDeleteLibraryRequest, options?: AxiosRequestConfig) { + return LibraryApiFp(this.configuration).deleteLibrary(requestParameters.id, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof LibraryApi + */ + public getAllForUser(options?: AxiosRequestConfig) { + return LibraryApiFp(this.configuration).getAllForUser(options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {LibraryApiGetLibraryInfoRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof LibraryApi + */ + public getLibraryInfo(requestParameters: LibraryApiGetLibraryInfoRequest, options?: AxiosRequestConfig) { + return LibraryApiFp(this.configuration).getLibraryInfo(requestParameters.id, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {LibraryApiGetLibraryStatisticsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof LibraryApi + */ + public getLibraryStatistics(requestParameters: LibraryApiGetLibraryStatisticsRequest, options?: AxiosRequestConfig) { + return LibraryApiFp(this.configuration).getLibraryStatistics(requestParameters.id, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {LibraryApiRemoveOfflineFilesRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof LibraryApi + */ + public removeOfflineFiles(requestParameters: LibraryApiRemoveOfflineFilesRequest, options?: AxiosRequestConfig) { + return LibraryApiFp(this.configuration).removeOfflineFiles(requestParameters.id, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {LibraryApiScanLibraryRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof LibraryApi + */ + public scanLibrary(requestParameters: LibraryApiScanLibraryRequest, options?: AxiosRequestConfig) { + return LibraryApiFp(this.configuration).scanLibrary(requestParameters.id, requestParameters.scanLibraryDto, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {LibraryApiUpdateLibraryRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof LibraryApi + */ + public updateLibrary(requestParameters: LibraryApiUpdateLibraryRequest, options?: AxiosRequestConfig) { + return LibraryApiFp(this.configuration).updateLibrary(requestParameters.id, requestParameters.updateLibraryDto, options).then((request) => request(this.axios, this.basePath)); + } +} + + /** * OAuthApi - axios parameter creator * @export diff --git a/docs/docs/features/libraries.md b/docs/docs/features/libraries.md new file mode 100644 index 0000000000..a17439aa7c --- /dev/null +++ b/docs/docs/features/libraries.md @@ -0,0 +1,148 @@ +# Libraries + +## Overview + +Immich supports the creation of libraries which is a top-level asset container. Currently, there are two types of libraries: traditional upload libraries that can sync with a mobile device, and external libraries, that keeps up to date with files on disk. Libraries are different from albums in that an asset can belong to multiple albums but only one library, and deleting a library deletes all assets contained within. As of August 2023, this is a new feature and libraries have a lot of potential for future development beyond what is documented here. This document attempts to describe the current state of libraries. + +## The Upload Library + +Immich comes preconfigured with an upload library for each user. All assets uploaded to Immich are added to this library. This library can be renamed, but not deleted. The upload library is the only library that can be synced with a mobile device. No items in an upload library is allowed to have the same sha1 hash as another item in the same library in order to prevent duplicates. + +## External Libraries + +External libraries tracks assets stored outside of immich, i.e. in the file system. Immich will only read data from the files, and will not modify them in any way. Therefore, the delete button is disabled for external assets. When the external library is scanned, immich will read the metadata from the file and create an asset in the library for each image or video file. These items will then be shown in the main timeline, and they will look and behave like any other asset, including viewing on the map, adding to albums, etc. + +If a file is modified outside of Immich, the changes will not be reflected in immich until the library is scanned again. There are different ways to scan a library depending on the use case: + +- Scan Library Files: This is the default scan method and also the quickest. It will scan all files in the library and add new files to the library. It will notice if any files are missing (see below) but not check existing assets +- Scan All Library Files: Same as above, but will check each existing asset to see if the modification time has changed. If it has, the asset will be updated. Since it has to check each asset, this is slower than Scan Library Files. +- Force Scan All Library Files: Same as above, but will read each asset from disk no matter the modification time. This is useful in some cases where an asset has been modified externally but the modification time has not changed. This is the slowest way to scan because it reads each asset from disk. + +:::caution + +Due to aggressive caching it can take some time for a refreshed asset to appear correctly in the web view. You need to clear the cache in your browser to see the changes. This is a known issue and will be fixed in a future release. In Chrome, you need to open the developer console with F12, then reload the page with F5, and finally right click on the reload button and select "Empty Cache and Hard Reload". + +::: + +In external libraries, the file path is used for duplicate detection. This means that if a file is moved to a different location, it will be added as a new asset. If the file is moved back to its original location, it will be added as a new asset. In contrast to upload libraries, two identical files can be uploaded if they are in different locations. This is a deliberate design choice to make Immich reflect the file system as closely as possible. Remember that duplication detection is only done within the same library, so if you have multiple external libraries, the same file can be added to multiple libraries. + +:::caution + +If you add assets from an external library to an album and then move the asset to another location within the library, the asset will be removed from the album upon rescan. This is because the asset is considered a new asset after the move. This is a known issue and will be fixed in a future release. + +::: + +### Deleted External Assets + +In all above scan methods, Immich will check if any files are missing. This can happen if files are deleted, or if they are on a storage location that is currently unavailable, like a network drive that is not mounted, or a USB drive that has been unplugged. In order to prevent accidental deletion of assets, Immich will not immediately delete an asset from the library if the file is missing. Instead, the asset will be internally marked as offline and will still be visible in the main timeline. If the file is moved back to its original location and the library is scanned again, the asset will be restored. + +Finally, files can be deleted from Immich via the `Remove Offline Files` job. Any assets marked as offline will then be removed from Immich. Run this job whenever files have been deleted from the file system and you want to remove them from Immich. Note that a library scan must be performed first to mark the assets as offline. + +### Import Paths + +External libraries use import paths to determine which files to scan. Each library can have multiple import paths so that files from different locations can be added to the same library. Import paths are scanned recursively, and if a file is in multiple import paths, it will only be added once. If the import paths are edited in a way that an external file is no longer in any import path, it will be removed from the library in the same way a deleted file would. If the file is moved back to an import path, it will be added again as if it was a new file. + +### Security Considerations + +For security purposes, each Immich user is disallowed to add external files by default. This is to prevent devastating [path traversal attacks](https://owasp.org/www-community/attacks/Path_Traversal). An admin can allow individual users to use external path feature via the `external path` setting found in the admin panel. Without the external path restriction, a user can add any image or video file on the Immich host filesystem to be imported into Immich, potentially allowing sensitive data to be accessed. If you are running Immich as root in your Docker setup (which is the default), all external file reads are done with root privileges. This is particularly dangerous if the Immich host is a shared server. + +With the `external path` set, a user is restricted to accessing external files to files or directories within that path. The Immich admin should still be careful not set the external path too generously. For example, `user1` wants to read their photos in to `/home/user1`. A lazy admin sets that user's external path to `/home/` since it "gets the job done". However, that user will then be able to read all photos in `/home/user2/private-photos`, too! Please set the external path as specific as possible. If multiple folders must be added, do this using the docker volume mount feature described below. + +### Exclusion Patterns and Scan Settings + +By default, all files in the import paths will be added to the library. If there are files that should not be added, exclusion patterns can be used to exclude them. Exclusion patterns are glob patterns are matched against the full file path. If a file matches an exclusion pattern, it will not be added to the library. Exclusion patterns can be added in the Scan Settings page for each library. Under the hood, Immich uses the [glob](https://www.npmjs.com/package/glob) package to match patterns, so please refer to [their documentation](https://github.com/isaacs/node-glob#glob-primer) to see what patterns are supported. + +Some basic examples: + +- `*.tif` will exclude all files with the extension `.tif` +- `hidden.jpg` will exclude all files named `hidden.jpg` +- `**/Raw/**` will exclude all files in any directory named `Raw` +- `*.(tif,jpg)` will exclude all files with the extension `.tif` or `.jpg` + +## Usage + +Let's show a concrete example where we add an existing gallery to Immich. Here, we have the following folders we want to add: + +- `/home/user/old-pics`: a folder contining childhood photos. +- `/mnt/nas/christmas-trip`: photos from a christmas trip. The subfolder `/mnt/nas/christmas-trip/Raw` contains the raw files directly from the DSLR. We don't want to import the raw files to Immich +- `/mnt/media/videos`: Videos from the same christmas trip. + +First, we need to plan how we want to organize the libraries. The christmas trip photos should belong to its own library since we want to exclude the raw files. The videos and old photos can be in the same library since we want to import all files. We could also add all three folders to the same library if there are no files matching the Raw exclusion pattern in the other folders. + +### Mount Docker Volumes + +`immich-server` and `immich-microservices` containers will need access to the gallery. Modify your docker compose file as follows + +```diff title="docker-compose.yml" + immich-server: + volumes: + - ${UPLOAD_LOCATION}:/usr/src/app/upload ++ - /mnt/nas/christmas-trip:/mnt/media/christmas-trip:ro ++ - /home/user/old-pics:/mnt/media/old-pics:ro ++ - /mnt/media/videos:/mnt/media/videos:ro + + + immich-microservices: + volumes: + - ${UPLOAD_LOCATION}:/usr/src/app/upload ++ - /mnt/nas/christmas-trip:/mnt/media/christmas-trip:ro ++ - /home/user/old-pics:/mnt/media/old-pics:ro ++ - /mnt/media/videos:/mnt/media/videos:ro +``` + +:::tip +The `ro` flag at the end only gives read-only access to the volumes. While Immich does not modify files, it's a good practice to mount read-only. +::: + +_Remember to bring the container down/up to register the changes. Make sure you can see the mounted path in the container._ + +### Set External Path + +Only an admin can do this. + +- Navigate to `Administration > Users` page on the web. +- Click on the user edit button. +- Set `/mnt/media` to be the external path. This folder will only contain the three folders that we want to import, so nothing else can be accessed. + +### Create External Libraries + +- Click on your user name in the top right corner -> Account Settings +- Click on Libraries +- Click on Create External Library +- Click the drop-down menu on the newly created library +- Click on Rename Library and rename it to "Christmas Trip" +- Click Edit Import Paths +- Click on Add Path +- Enter `/mnt/media/christmas-trip` then click Add + +NOTE: We have to use the `/mnt/media/christmas-trip` path and not the `/mnt/nas/christmas-trip` path since all paths have to be what the Docker containers see. + +Next, we'll add an exclusion pattern to filter out raw files. + +- Click the drop-down menu on the newly christmas library +- Click on Manage +- Click on Scan Settings +- Click on Add Exclusion Pattern +- Enter `**/Raw/**` and click save. +- Click save +- Click the drop-down menu on the newly created library +- Click on Scan Library Files + +The christmas trip library will now be scanned in the background. In the meantime, let's add the videos and old photos to another library. + +- Click on Create External Library. + +:::info Note +If you get an error here, please rename the other external library to something else. This is a bug that will be fixed in a future release. +::: + +- Click the drop-down menu on the newly created library +- Click Edit Import Paths +- Click on Add Path +- Enter `/mnt/media/old-pics` then click Add +- Click on Add Path +- Enter `/mnt/media/videos` then click Add +- Click Save +- Click on Scan Library Files + +Within seconds, the assets from the old-pics and videos folders should show up in the main timeline. diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 40490a32de..f10fa425c5 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -46,6 +46,7 @@ doc/CheckExistingAssetsResponseDto.md doc/ClassificationConfig.md doc/Colorspace.md doc/CreateAlbumDto.md +doc/CreateLibraryDto.md doc/CreateProfileImageResponseDto.md doc/CreateTagDto.md doc/CreateUserDto.md @@ -67,6 +68,10 @@ doc/JobCountsDto.md doc/JobName.md doc/JobSettingsDto.md doc/JobStatusDto.md +doc/LibraryApi.md +doc/LibraryResponseDto.md +doc/LibraryStatsResponseDto.md +doc/LibraryType.md doc/LoginCredentialDto.md doc/LoginResponseDto.md doc/LogoutResponseDto.md @@ -88,6 +93,7 @@ doc/PersonResponseDto.md doc/PersonUpdateDto.md doc/QueueStatusDto.md doc/RecognitionConfig.md +doc/ScanLibraryDto.md doc/SearchAlbumResponseDto.md doc/SearchApi.md doc/SearchAssetDto.md @@ -134,6 +140,7 @@ doc/TranscodeHWAccel.md doc/TranscodePolicy.md doc/UpdateAlbumDto.md doc/UpdateAssetDto.md +doc/UpdateLibraryDto.md doc/UpdateTagDto.md doc/UpdateUserDto.md doc/UsageByUserDto.md @@ -150,6 +157,7 @@ lib/api/asset_api.dart lib/api/audit_api.dart lib/api/authentication_api.dart lib/api/job_api.dart +lib/api/library_api.dart lib/api/o_auth_api.dart lib/api/partner_api.dart lib/api/person_api.dart @@ -205,6 +213,7 @@ lib/model/clip_mode.dart lib/model/colorspace.dart lib/model/cq_mode.dart lib/model/create_album_dto.dart +lib/model/create_library_dto.dart lib/model/create_profile_image_response_dto.dart lib/model/create_tag_dto.dart lib/model/create_user_dto.dart @@ -225,6 +234,9 @@ lib/model/job_counts_dto.dart lib/model/job_name.dart lib/model/job_settings_dto.dart lib/model/job_status_dto.dart +lib/model/library_response_dto.dart +lib/model/library_stats_response_dto.dart +lib/model/library_type.dart lib/model/login_credential_dto.dart lib/model/login_response_dto.dart lib/model/logout_response_dto.dart @@ -243,6 +255,7 @@ lib/model/person_response_dto.dart lib/model/person_update_dto.dart lib/model/queue_status_dto.dart lib/model/recognition_config.dart +lib/model/scan_library_dto.dart lib/model/search_album_response_dto.dart lib/model/search_asset_dto.dart lib/model/search_asset_response_dto.dart @@ -284,6 +297,7 @@ lib/model/transcode_hw_accel.dart lib/model/transcode_policy.dart lib/model/update_album_dto.dart lib/model/update_asset_dto.dart +lib/model/update_library_dto.dart lib/model/update_tag_dto.dart lib/model/update_user_dto.dart lib/model/usage_by_user_dto.dart @@ -335,6 +349,7 @@ test/clip_mode_test.dart test/colorspace_test.dart test/cq_mode_test.dart test/create_album_dto_test.dart +test/create_library_dto_test.dart test/create_profile_image_response_dto_test.dart test/create_tag_dto_test.dart test/create_user_dto_test.dart @@ -356,6 +371,10 @@ test/job_counts_dto_test.dart test/job_name_test.dart test/job_settings_dto_test.dart test/job_status_dto_test.dart +test/library_api_test.dart +test/library_response_dto_test.dart +test/library_stats_response_dto_test.dart +test/library_type_test.dart test/login_credential_dto_test.dart test/login_response_dto_test.dart test/logout_response_dto_test.dart @@ -377,6 +396,7 @@ test/person_response_dto_test.dart test/person_update_dto_test.dart test/queue_status_dto_test.dart test/recognition_config_test.dart +test/scan_library_dto_test.dart test/search_album_response_dto_test.dart test/search_api_test.dart test/search_asset_dto_test.dart @@ -423,6 +443,7 @@ test/transcode_hw_accel_test.dart test/transcode_policy_test.dart test/update_album_dto_test.dart test/update_asset_dto_test.dart +test/update_library_dto_test.dart test/update_tag_dto_test.dart test/update_user_dto_test.dart test/usage_by_user_dto_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 6ac475e5bd..3b248c01e0 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -124,6 +124,14 @@ Class | Method | HTTP request | Description *AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken | *JobApi* | [**getAllJobsStatus**](doc//JobApi.md#getalljobsstatus) | **GET** /jobs | *JobApi* | [**sendJobCommand**](doc//JobApi.md#sendjobcommand) | **PUT** /jobs/{id} | +*LibraryApi* | [**createLibrary**](doc//LibraryApi.md#createlibrary) | **POST** /library | +*LibraryApi* | [**deleteLibrary**](doc//LibraryApi.md#deletelibrary) | **DELETE** /library/{id} | +*LibraryApi* | [**getAllForUser**](doc//LibraryApi.md#getallforuser) | **GET** /library | +*LibraryApi* | [**getLibraryInfo**](doc//LibraryApi.md#getlibraryinfo) | **GET** /library/{id} | +*LibraryApi* | [**getLibraryStatistics**](doc//LibraryApi.md#getlibrarystatistics) | **GET** /library/{id}/statistics | +*LibraryApi* | [**removeOfflineFiles**](doc//LibraryApi.md#removeofflinefiles) | **POST** /library/{id}/removeOffline | +*LibraryApi* | [**scanLibrary**](doc//LibraryApi.md#scanlibrary) | **POST** /library/{id}/scan | +*LibraryApi* | [**updateLibrary**](doc//LibraryApi.md#updatelibrary) | **PUT** /library/{id} | *OAuthApi* | [**authorizeOAuth**](doc//OAuthApi.md#authorizeoauth) | **POST** /oauth/authorize | *OAuthApi* | [**callback**](doc//OAuthApi.md#callback) | **POST** /oauth/callback | *OAuthApi* | [**generateConfig**](doc//OAuthApi.md#generateconfig) | **POST** /oauth/config | @@ -221,6 +229,7 @@ Class | Method | HTTP request | Description - [ClassificationConfig](doc//ClassificationConfig.md) - [Colorspace](doc//Colorspace.md) - [CreateAlbumDto](doc//CreateAlbumDto.md) + - [CreateLibraryDto](doc//CreateLibraryDto.md) - [CreateProfileImageResponseDto](doc//CreateProfileImageResponseDto.md) - [CreateTagDto](doc//CreateTagDto.md) - [CreateUserDto](doc//CreateUserDto.md) @@ -241,6 +250,9 @@ Class | Method | HTTP request | Description - [JobName](doc//JobName.md) - [JobSettingsDto](doc//JobSettingsDto.md) - [JobStatusDto](doc//JobStatusDto.md) + - [LibraryResponseDto](doc//LibraryResponseDto.md) + - [LibraryStatsResponseDto](doc//LibraryStatsResponseDto.md) + - [LibraryType](doc//LibraryType.md) - [LoginCredentialDto](doc//LoginCredentialDto.md) - [LoginResponseDto](doc//LoginResponseDto.md) - [LogoutResponseDto](doc//LogoutResponseDto.md) @@ -259,6 +271,7 @@ Class | Method | HTTP request | Description - [PersonUpdateDto](doc//PersonUpdateDto.md) - [QueueStatusDto](doc//QueueStatusDto.md) - [RecognitionConfig](doc//RecognitionConfig.md) + - [ScanLibraryDto](doc//ScanLibraryDto.md) - [SearchAlbumResponseDto](doc//SearchAlbumResponseDto.md) - [SearchAssetDto](doc//SearchAssetDto.md) - [SearchAssetResponseDto](doc//SearchAssetResponseDto.md) @@ -300,6 +313,7 @@ Class | Method | HTTP request | Description - [TranscodePolicy](doc//TranscodePolicy.md) - [UpdateAlbumDto](doc//UpdateAlbumDto.md) - [UpdateAssetDto](doc//UpdateAssetDto.md) + - [UpdateLibraryDto](doc//UpdateLibraryDto.md) - [UpdateTagDto](doc//UpdateTagDto.md) - [UpdateUserDto](doc//UpdateUserDto.md) - [UsageByUserDto](doc//UsageByUserDto.md) diff --git a/mobile/openapi/doc/AllJobStatusResponseDto.md b/mobile/openapi/doc/AllJobStatusResponseDto.md index 18db99f19e..d2e64304fc 100644 --- a/mobile/openapi/doc/AllJobStatusResponseDto.md +++ b/mobile/openapi/doc/AllJobStatusResponseDto.md @@ -10,6 +10,7 @@ Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- **backgroundTask** | [**JobStatusDto**](JobStatusDto.md) | | **clipEncoding** | [**JobStatusDto**](JobStatusDto.md) | | +**library_** | [**JobStatusDto**](JobStatusDto.md) | | **metadataExtraction** | [**JobStatusDto**](JobStatusDto.md) | | **objectTagging** | [**JobStatusDto**](JobStatusDto.md) | | **recognizeFaces** | [**JobStatusDto**](JobStatusDto.md) | | diff --git a/mobile/openapi/doc/AssetApi.md b/mobile/openapi/doc/AssetApi.md index f977c8ed4b..8b4581d0a8 100644 --- a/mobile/openapi/doc/AssetApi.md +++ b/mobile/openapi/doc/AssetApi.md @@ -1475,7 +1475,7 @@ void (empty response body) [[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) # **uploadFile** -> AssetFileUploadResponseDto uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, key, duration, isArchived, isReadOnly, isVisible, livePhotoData, sidecarData) +> AssetFileUploadResponseDto uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, key, duration, isArchived, isExternal, isOffline, isReadOnly, isVisible, libraryId, livePhotoData, sidecarData) @@ -1507,13 +1507,16 @@ final isFavorite = true; // bool | final key = key_example; // String | final duration = duration_example; // String | final isArchived = true; // bool | +final isExternal = true; // bool | +final isOffline = true; // bool | final isReadOnly = true; // bool | final isVisible = true; // bool | +final libraryId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | final livePhotoData = BINARY_DATA_HERE; // MultipartFile | final sidecarData = BINARY_DATA_HERE; // MultipartFile | try { - final result = api_instance.uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, key, duration, isArchived, isReadOnly, isVisible, livePhotoData, sidecarData); + final result = api_instance.uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, key, duration, isArchived, isExternal, isOffline, isReadOnly, isVisible, libraryId, livePhotoData, sidecarData); print(result); } catch (e) { print('Exception when calling AssetApi->uploadFile: $e\n'); @@ -1533,8 +1536,11 @@ Name | Type | Description | Notes **key** | **String**| | [optional] **duration** | **String**| | [optional] **isArchived** | **bool**| | [optional] - **isReadOnly** | **bool**| | [optional] [default to false] + **isExternal** | **bool**| | [optional] + **isOffline** | **bool**| | [optional] + **isReadOnly** | **bool**| | [optional] **isVisible** | **bool**| | [optional] + **libraryId** | **String**| | [optional] **livePhotoData** | **MultipartFile**| | [optional] **sidecarData** | **MultipartFile**| | [optional] diff --git a/mobile/openapi/doc/AssetResponseDto.md b/mobile/openapi/doc/AssetResponseDto.md index 929031a3a3..34d7e444cc 100644 --- a/mobile/openapi/doc/AssetResponseDto.md +++ b/mobile/openapi/doc/AssetResponseDto.md @@ -17,7 +17,11 @@ Name | Type | Description | Notes **fileModifiedAt** | [**DateTime**](DateTime.md) | | **id** | **String** | | **isArchived** | **bool** | | +**isExternal** | **bool** | | **isFavorite** | **bool** | | +**isOffline** | **bool** | | +**isReadOnly** | **bool** | | +**libraryId** | **String** | | **livePhotoVideoId** | **String** | | [optional] **originalFileName** | **String** | | **originalPath** | **String** | | diff --git a/mobile/openapi/doc/CreateLibraryDto.md b/mobile/openapi/doc/CreateLibraryDto.md new file mode 100644 index 0000000000..c2ccf9bf1e --- /dev/null +++ b/mobile/openapi/doc/CreateLibraryDto.md @@ -0,0 +1,19 @@ +# openapi.model.CreateLibraryDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**exclusionPatterns** | **List** | | [optional] [default to const []] +**importPaths** | **List** | | [optional] [default to const []] +**isVisible** | **bool** | | [optional] +**name** | **String** | | [optional] +**type** | [**LibraryType**](LibraryType.md) | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/doc/ImportAssetDto.md b/mobile/openapi/doc/ImportAssetDto.md index 2444c47493..c9a5f25d2d 100644 --- a/mobile/openapi/doc/ImportAssetDto.md +++ b/mobile/openapi/doc/ImportAssetDto.md @@ -15,9 +15,12 @@ Name | Type | Description | Notes **fileCreatedAt** | [**DateTime**](DateTime.md) | | **fileModifiedAt** | [**DateTime**](DateTime.md) | | **isArchived** | **bool** | | [optional] +**isExternal** | **bool** | | [optional] **isFavorite** | **bool** | | +**isOffline** | **bool** | | [optional] **isReadOnly** | **bool** | | [optional] [default to true] **isVisible** | **bool** | | [optional] +**libraryId** | **String** | | [optional] **sidecarPath** | **String** | | [optional] [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/mobile/openapi/doc/LibraryApi.md b/mobile/openapi/doc/LibraryApi.md new file mode 100644 index 0000000000..2362dfaeac --- /dev/null +++ b/mobile/openapi/doc/LibraryApi.md @@ -0,0 +1,458 @@ +# openapi.api.LibraryApi + +## Load the API package +```dart +import 'package:openapi/api.dart'; +``` + +All URIs are relative to */api* + +Method | HTTP request | Description +------------- | ------------- | ------------- +[**createLibrary**](LibraryApi.md#createlibrary) | **POST** /library | +[**deleteLibrary**](LibraryApi.md#deletelibrary) | **DELETE** /library/{id} | +[**getAllForUser**](LibraryApi.md#getallforuser) | **GET** /library | +[**getLibraryInfo**](LibraryApi.md#getlibraryinfo) | **GET** /library/{id} | +[**getLibraryStatistics**](LibraryApi.md#getlibrarystatistics) | **GET** /library/{id}/statistics | +[**removeOfflineFiles**](LibraryApi.md#removeofflinefiles) | **POST** /library/{id}/removeOffline | +[**scanLibrary**](LibraryApi.md#scanlibrary) | **POST** /library/{id}/scan | +[**updateLibrary**](LibraryApi.md#updatelibrary) | **PUT** /library/{id} | + + +# **createLibrary** +> LibraryResponseDto createLibrary(createLibraryDto) + + + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); + +final api_instance = LibraryApi(); +final createLibraryDto = CreateLibraryDto(); // CreateLibraryDto | + +try { + final result = api_instance.createLibrary(createLibraryDto); + print(result); +} catch (e) { + print('Exception when calling LibraryApi->createLibrary: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **createLibraryDto** | [**CreateLibraryDto**](CreateLibraryDto.md)| | + +### Return type + +[**LibraryResponseDto**](LibraryResponseDto.md) + +### Authorization + +[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) + +### HTTP request headers + + - **Content-Type**: application/json + - **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) + +# **deleteLibrary** +> deleteLibrary(id) + + + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); + +final api_instance = LibraryApi(); +final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | + +try { + api_instance.deleteLibrary(id); +} catch (e) { + print('Exception when calling LibraryApi->deleteLibrary: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **id** | **String**| | + +### Return type + +void (empty response body) + +### Authorization + +[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: Not defined + +[[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) + +# **getAllForUser** +> List getAllForUser() + + + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); + +final api_instance = LibraryApi(); + +try { + final result = api_instance.getAllForUser(); + print(result); +} catch (e) { + print('Exception when calling LibraryApi->getAllForUser: $e\n'); +} +``` + +### Parameters +This endpoint does not need any parameter. + +### Return type + +[**List**](LibraryResponseDto.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) + +# **getLibraryInfo** +> LibraryResponseDto getLibraryInfo(id) + + + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); + +final api_instance = LibraryApi(); +final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | + +try { + final result = api_instance.getLibraryInfo(id); + print(result); +} catch (e) { + print('Exception when calling LibraryApi->getLibraryInfo: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **id** | **String**| | + +### Return type + +[**LibraryResponseDto**](LibraryResponseDto.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) + +# **getLibraryStatistics** +> LibraryStatsResponseDto getLibraryStatistics(id) + + + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); + +final api_instance = LibraryApi(); +final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | + +try { + final result = api_instance.getLibraryStatistics(id); + print(result); +} catch (e) { + print('Exception when calling LibraryApi->getLibraryStatistics: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **id** | **String**| | + +### Return type + +[**LibraryStatsResponseDto**](LibraryStatsResponseDto.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) + +# **removeOfflineFiles** +> removeOfflineFiles(id) + + + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); + +final api_instance = LibraryApi(); +final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | + +try { + api_instance.removeOfflineFiles(id); +} catch (e) { + print('Exception when calling LibraryApi->removeOfflineFiles: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **id** | **String**| | + +### Return type + +void (empty response body) + +### Authorization + +[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: Not defined + +[[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) + +# **scanLibrary** +> scanLibrary(id, scanLibraryDto) + + + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); + +final api_instance = LibraryApi(); +final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | +final scanLibraryDto = ScanLibraryDto(); // ScanLibraryDto | + +try { + api_instance.scanLibrary(id, scanLibraryDto); +} catch (e) { + print('Exception when calling LibraryApi->scanLibrary: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **id** | **String**| | + **scanLibraryDto** | [**ScanLibraryDto**](ScanLibraryDto.md)| | + +### Return type + +void (empty response body) + +### Authorization + +[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) + +### HTTP request headers + + - **Content-Type**: application/json + - **Accept**: Not defined + +[[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) + +# **updateLibrary** +> LibraryResponseDto updateLibrary(id, updateLibraryDto) + + + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); + +final api_instance = LibraryApi(); +final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | +final updateLibraryDto = UpdateLibraryDto(); // UpdateLibraryDto | + +try { + final result = api_instance.updateLibrary(id, updateLibraryDto); + print(result); +} catch (e) { + print('Exception when calling LibraryApi->updateLibrary: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **id** | **String**| | + **updateLibraryDto** | [**UpdateLibraryDto**](UpdateLibraryDto.md)| | + +### Return type + +[**LibraryResponseDto**](LibraryResponseDto.md) + +### Authorization + +[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) + +### HTTP request headers + + - **Content-Type**: application/json + - **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) + diff --git a/mobile/openapi/doc/LibraryResponseDto.md b/mobile/openapi/doc/LibraryResponseDto.md new file mode 100644 index 0000000000..e7283c11b6 --- /dev/null +++ b/mobile/openapi/doc/LibraryResponseDto.md @@ -0,0 +1,24 @@ +# openapi.model.LibraryResponseDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**assetCount** | **int** | | +**createdAt** | [**DateTime**](DateTime.md) | | +**exclusionPatterns** | **List** | | [default to const []] +**id** | **String** | | +**importPaths** | **List** | | [default to const []] +**name** | **String** | | +**ownerId** | **String** | | +**refreshedAt** | [**DateTime**](DateTime.md) | | +**type** | [**LibraryType**](LibraryType.md) | | +**updatedAt** | [**DateTime**](DateTime.md) | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/doc/LibraryStatsResponseDto.md b/mobile/openapi/doc/LibraryStatsResponseDto.md new file mode 100644 index 0000000000..18c9fdb121 --- /dev/null +++ b/mobile/openapi/doc/LibraryStatsResponseDto.md @@ -0,0 +1,18 @@ +# openapi.model.LibraryStatsResponseDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**photos** | **int** | | [default to 0] +**total** | **int** | | [default to 0] +**usage** | **int** | | [default to 0] +**videos** | **int** | | [default to 0] + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/doc/LibraryType.md b/mobile/openapi/doc/LibraryType.md new file mode 100644 index 0000000000..0bd5a3a14b --- /dev/null +++ b/mobile/openapi/doc/LibraryType.md @@ -0,0 +1,14 @@ +# openapi.model.LibraryType + +## 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) + + diff --git a/mobile/openapi/doc/ScanLibraryDto.md b/mobile/openapi/doc/ScanLibraryDto.md new file mode 100644 index 0000000000..39f55290dc --- /dev/null +++ b/mobile/openapi/doc/ScanLibraryDto.md @@ -0,0 +1,16 @@ +# openapi.model.ScanLibraryDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**refreshAllFiles** | **bool** | | [optional] [default to false] +**refreshModifiedFiles** | **bool** | | [optional] + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/doc/SystemConfigJobDto.md b/mobile/openapi/doc/SystemConfigJobDto.md index 7ded1184aa..d2738d19d5 100644 --- a/mobile/openapi/doc/SystemConfigJobDto.md +++ b/mobile/openapi/doc/SystemConfigJobDto.md @@ -10,6 +10,7 @@ Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- **backgroundTask** | [**JobSettingsDto**](JobSettingsDto.md) | | **clipEncoding** | [**JobSettingsDto**](JobSettingsDto.md) | | +**library_** | [**JobSettingsDto**](JobSettingsDto.md) | | **metadataExtraction** | [**JobSettingsDto**](JobSettingsDto.md) | | **objectTagging** | [**JobSettingsDto**](JobSettingsDto.md) | | **recognizeFaces** | [**JobSettingsDto**](JobSettingsDto.md) | | diff --git a/mobile/openapi/doc/UpdateLibraryDto.md b/mobile/openapi/doc/UpdateLibraryDto.md new file mode 100644 index 0000000000..0f0e2652b8 --- /dev/null +++ b/mobile/openapi/doc/UpdateLibraryDto.md @@ -0,0 +1,18 @@ +# openapi.model.UpdateLibraryDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**exclusionPatterns** | **List** | | [optional] [default to const []] +**importPaths** | **List** | | [optional] [default to const []] +**isVisible** | **bool** | | [optional] +**name** | **String** | | [optional] + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index cec7ba904f..1ff40f5ed6 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -34,6 +34,7 @@ part 'api/asset_api.dart'; part 'api/audit_api.dart'; part 'api/authentication_api.dart'; part 'api/job_api.dart'; +part 'api/library_api.dart'; part 'api/o_auth_api.dart'; part 'api/partner_api.dart'; part 'api/person_api.dart'; @@ -82,6 +83,7 @@ part 'model/check_existing_assets_response_dto.dart'; part 'model/classification_config.dart'; part 'model/colorspace.dart'; part 'model/create_album_dto.dart'; +part 'model/create_library_dto.dart'; part 'model/create_profile_image_response_dto.dart'; part 'model/create_tag_dto.dart'; part 'model/create_user_dto.dart'; @@ -102,6 +104,9 @@ part 'model/job_counts_dto.dart'; part 'model/job_name.dart'; part 'model/job_settings_dto.dart'; part 'model/job_status_dto.dart'; +part 'model/library_response_dto.dart'; +part 'model/library_stats_response_dto.dart'; +part 'model/library_type.dart'; part 'model/login_credential_dto.dart'; part 'model/login_response_dto.dart'; part 'model/logout_response_dto.dart'; @@ -120,6 +125,7 @@ part 'model/person_response_dto.dart'; part 'model/person_update_dto.dart'; part 'model/queue_status_dto.dart'; part 'model/recognition_config.dart'; +part 'model/scan_library_dto.dart'; part 'model/search_album_response_dto.dart'; part 'model/search_asset_dto.dart'; part 'model/search_asset_response_dto.dart'; @@ -161,6 +167,7 @@ part 'model/transcode_hw_accel.dart'; part 'model/transcode_policy.dart'; part 'model/update_album_dto.dart'; part 'model/update_asset_dto.dart'; +part 'model/update_library_dto.dart'; part 'model/update_tag_dto.dart'; part 'model/update_user_dto.dart'; part 'model/usage_by_user_dto.dart'; diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index 86ef067b9f..ac828f730b 100644 --- a/mobile/openapi/lib/api/asset_api.dart +++ b/mobile/openapi/lib/api/asset_api.dart @@ -1496,14 +1496,20 @@ class AssetApi { /// /// * [bool] isArchived: /// + /// * [bool] isExternal: + /// + /// * [bool] isOffline: + /// /// * [bool] isReadOnly: /// /// * [bool] isVisible: /// + /// * [String] libraryId: + /// /// * [MultipartFile] livePhotoData: /// /// * [MultipartFile] sidecarData: - Future uploadFileWithHttpInfo(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, bool isFavorite, { String? key, String? duration, bool? isArchived, bool? isReadOnly, bool? isVisible, MultipartFile? livePhotoData, MultipartFile? sidecarData, }) async { + Future uploadFileWithHttpInfo(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, bool isFavorite, { String? key, String? duration, bool? isArchived, bool? isExternal, bool? isOffline, bool? isReadOnly, bool? isVisible, String? libraryId, MultipartFile? livePhotoData, MultipartFile? sidecarData, }) async { // ignore: prefer_const_declarations final path = r'/asset/upload'; @@ -1551,10 +1557,18 @@ class AssetApi { hasFields = true; mp.fields[r'isArchived'] = parameterToString(isArchived); } + if (isExternal != null) { + hasFields = true; + mp.fields[r'isExternal'] = parameterToString(isExternal); + } if (isFavorite != null) { hasFields = true; mp.fields[r'isFavorite'] = parameterToString(isFavorite); } + if (isOffline != null) { + hasFields = true; + mp.fields[r'isOffline'] = parameterToString(isOffline); + } if (isReadOnly != null) { hasFields = true; mp.fields[r'isReadOnly'] = parameterToString(isReadOnly); @@ -1563,6 +1577,10 @@ class AssetApi { hasFields = true; mp.fields[r'isVisible'] = parameterToString(isVisible); } + if (libraryId != null) { + hasFields = true; + mp.fields[r'libraryId'] = parameterToString(libraryId); + } if (livePhotoData != null) { hasFields = true; mp.fields[r'livePhotoData'] = livePhotoData.field; @@ -1608,15 +1626,21 @@ class AssetApi { /// /// * [bool] isArchived: /// + /// * [bool] isExternal: + /// + /// * [bool] isOffline: + /// /// * [bool] isReadOnly: /// /// * [bool] isVisible: /// + /// * [String] libraryId: + /// /// * [MultipartFile] livePhotoData: /// /// * [MultipartFile] sidecarData: - Future uploadFile(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, bool isFavorite, { String? key, String? duration, bool? isArchived, bool? isReadOnly, bool? isVisible, MultipartFile? livePhotoData, MultipartFile? sidecarData, }) async { - final response = await uploadFileWithHttpInfo(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, key: key, duration: duration, isArchived: isArchived, isReadOnly: isReadOnly, isVisible: isVisible, livePhotoData: livePhotoData, sidecarData: sidecarData, ); + Future uploadFile(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, bool isFavorite, { String? key, String? duration, bool? isArchived, bool? isExternal, bool? isOffline, bool? isReadOnly, bool? isVisible, String? libraryId, MultipartFile? livePhotoData, MultipartFile? sidecarData, }) async { + final response = await uploadFileWithHttpInfo(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, key: key, duration: duration, isArchived: isArchived, isExternal: isExternal, isOffline: isOffline, isReadOnly: isReadOnly, isVisible: isVisible, libraryId: libraryId, livePhotoData: livePhotoData, sidecarData: sidecarData, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/api/library_api.dart b/mobile/openapi/lib/api/library_api.dart new file mode 100644 index 0000000000..ec08a646de --- /dev/null +++ b/mobile/openapi/lib/api/library_api.dart @@ -0,0 +1,381 @@ +// +// 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 LibraryApi { + LibraryApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; + + final ApiClient apiClient; + + /// Performs an HTTP 'POST /library' operation and returns the [Response]. + /// Parameters: + /// + /// * [CreateLibraryDto] createLibraryDto (required): + Future createLibraryWithHttpInfo(CreateLibraryDto createLibraryDto,) async { + // ignore: prefer_const_declarations + final path = r'/library'; + + // ignore: prefer_final_locals + Object? postBody = createLibraryDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [CreateLibraryDto] createLibraryDto (required): + Future createLibrary(CreateLibraryDto createLibraryDto,) async { + final response = await createLibraryWithHttpInfo(createLibraryDto,); + 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) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'LibraryResponseDto',) as LibraryResponseDto; + + } + return null; + } + + /// Performs an HTTP 'DELETE /library/{id}' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + Future deleteLibraryWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final path = r'/library/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + Future deleteLibrary(String id,) async { + final response = await deleteLibraryWithHttpInfo(id,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + + /// Performs an HTTP 'GET /library' operation and returns the [Response]. + Future getAllForUserWithHttpInfo() async { + // ignore: prefer_const_declarations + final path = r'/library'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future?> getAllForUser() async { + final response = await getAllForUserWithHttpInfo(); + 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(); + + } + return null; + } + + /// Performs an HTTP 'GET /library/{id}' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + Future getLibraryInfoWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final path = r'/library/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + Future getLibraryInfo(String id,) async { + final response = await getLibraryInfoWithHttpInfo(id,); + 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) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'LibraryResponseDto',) as LibraryResponseDto; + + } + return null; + } + + /// Performs an HTTP 'GET /library/{id}/statistics' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + Future getLibraryStatisticsWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final path = r'/library/{id}/statistics' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + Future getLibraryStatistics(String id,) async { + final response = await getLibraryStatisticsWithHttpInfo(id,); + 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) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'LibraryStatsResponseDto',) as LibraryStatsResponseDto; + + } + return null; + } + + /// Performs an HTTP 'POST /library/{id}/removeOffline' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + Future removeOfflineFilesWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final path = r'/library/{id}/removeOffline' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + Future removeOfflineFiles(String id,) async { + final response = await removeOfflineFilesWithHttpInfo(id,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + + /// Performs an HTTP 'POST /library/{id}/scan' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [ScanLibraryDto] scanLibraryDto (required): + Future scanLibraryWithHttpInfo(String id, ScanLibraryDto scanLibraryDto,) async { + // ignore: prefer_const_declarations + final path = r'/library/{id}/scan' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody = scanLibraryDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [ScanLibraryDto] scanLibraryDto (required): + Future scanLibrary(String id, ScanLibraryDto scanLibraryDto,) async { + final response = await scanLibraryWithHttpInfo(id, scanLibraryDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + + /// Performs an HTTP 'PUT /library/{id}' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [UpdateLibraryDto] updateLibraryDto (required): + Future updateLibraryWithHttpInfo(String id, UpdateLibraryDto updateLibraryDto,) async { + // ignore: prefer_const_declarations + final path = r'/library/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody = updateLibraryDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [UpdateLibraryDto] updateLibraryDto (required): + Future updateLibrary(String id, UpdateLibraryDto updateLibraryDto,) async { + final response = await updateLibraryWithHttpInfo(id, updateLibraryDto,); + 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) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'LibraryResponseDto',) as LibraryResponseDto; + + } + return null; + } +} diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 063e1cb27d..e20a8d6984 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -257,6 +257,8 @@ class ApiClient { return ColorspaceTypeTransformer().decode(value); case 'CreateAlbumDto': return CreateAlbumDto.fromJson(value); + case 'CreateLibraryDto': + return CreateLibraryDto.fromJson(value); case 'CreateProfileImageResponseDto': return CreateProfileImageResponseDto.fromJson(value); case 'CreateTagDto': @@ -297,6 +299,12 @@ class ApiClient { return JobSettingsDto.fromJson(value); case 'JobStatusDto': return JobStatusDto.fromJson(value); + case 'LibraryResponseDto': + return LibraryResponseDto.fromJson(value); + case 'LibraryStatsResponseDto': + return LibraryStatsResponseDto.fromJson(value); + case 'LibraryType': + return LibraryTypeTypeTransformer().decode(value); case 'LoginCredentialDto': return LoginCredentialDto.fromJson(value); case 'LoginResponseDto': @@ -333,6 +341,8 @@ class ApiClient { return QueueStatusDto.fromJson(value); case 'RecognitionConfig': return RecognitionConfig.fromJson(value); + case 'ScanLibraryDto': + return ScanLibraryDto.fromJson(value); case 'SearchAlbumResponseDto': return SearchAlbumResponseDto.fromJson(value); case 'SearchAssetDto': @@ -415,6 +425,8 @@ class ApiClient { return UpdateAlbumDto.fromJson(value); case 'UpdateAssetDto': return UpdateAssetDto.fromJson(value); + case 'UpdateLibraryDto': + return UpdateLibraryDto.fromJson(value); case 'UpdateTagDto': return UpdateTagDto.fromJson(value); case 'UpdateUserDto': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index b3205a0c9f..3b6099f79d 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -85,6 +85,9 @@ String parameterToString(dynamic value) { if (value is JobName) { return JobNameTypeTransformer().encode(value).toString(); } + if (value is LibraryType) { + return LibraryTypeTypeTransformer().encode(value).toString(); + } if (value is ModelType) { return ModelTypeTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/all_job_status_response_dto.dart b/mobile/openapi/lib/model/all_job_status_response_dto.dart index 17c443abef..65736edcf5 100644 --- a/mobile/openapi/lib/model/all_job_status_response_dto.dart +++ b/mobile/openapi/lib/model/all_job_status_response_dto.dart @@ -15,6 +15,7 @@ class AllJobStatusResponseDto { AllJobStatusResponseDto({ required this.backgroundTask, required this.clipEncoding, + required this.library_, required this.metadataExtraction, required this.objectTagging, required this.recognizeFaces, @@ -29,6 +30,8 @@ class AllJobStatusResponseDto { JobStatusDto clipEncoding; + JobStatusDto library_; + JobStatusDto metadataExtraction; JobStatusDto objectTagging; @@ -49,6 +52,7 @@ class AllJobStatusResponseDto { bool operator ==(Object other) => identical(this, other) || other is AllJobStatusResponseDto && other.backgroundTask == backgroundTask && other.clipEncoding == clipEncoding && + other.library_ == library_ && other.metadataExtraction == metadataExtraction && other.objectTagging == objectTagging && other.recognizeFaces == recognizeFaces && @@ -63,6 +67,7 @@ class AllJobStatusResponseDto { // ignore: unnecessary_parenthesis (backgroundTask.hashCode) + (clipEncoding.hashCode) + + (library_.hashCode) + (metadataExtraction.hashCode) + (objectTagging.hashCode) + (recognizeFaces.hashCode) + @@ -73,12 +78,13 @@ class AllJobStatusResponseDto { (videoConversion.hashCode); @override - String toString() => 'AllJobStatusResponseDto[backgroundTask=$backgroundTask, clipEncoding=$clipEncoding, metadataExtraction=$metadataExtraction, objectTagging=$objectTagging, recognizeFaces=$recognizeFaces, search=$search, sidecar=$sidecar, storageTemplateMigration=$storageTemplateMigration, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion]'; + String toString() => 'AllJobStatusResponseDto[backgroundTask=$backgroundTask, clipEncoding=$clipEncoding, library_=$library_, metadataExtraction=$metadataExtraction, objectTagging=$objectTagging, recognizeFaces=$recognizeFaces, search=$search, sidecar=$sidecar, storageTemplateMigration=$storageTemplateMigration, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion]'; Map toJson() { final json = {}; json[r'backgroundTask'] = this.backgroundTask; json[r'clipEncoding'] = this.clipEncoding; + json[r'library'] = this.library_; json[r'metadataExtraction'] = this.metadataExtraction; json[r'objectTagging'] = this.objectTagging; json[r'recognizeFaces'] = this.recognizeFaces; @@ -100,6 +106,7 @@ class AllJobStatusResponseDto { return AllJobStatusResponseDto( backgroundTask: JobStatusDto.fromJson(json[r'backgroundTask'])!, clipEncoding: JobStatusDto.fromJson(json[r'clipEncoding'])!, + library_: JobStatusDto.fromJson(json[r'library'])!, metadataExtraction: JobStatusDto.fromJson(json[r'metadataExtraction'])!, objectTagging: JobStatusDto.fromJson(json[r'objectTagging'])!, recognizeFaces: JobStatusDto.fromJson(json[r'recognizeFaces'])!, @@ -157,6 +164,7 @@ class AllJobStatusResponseDto { static const requiredKeys = { 'backgroundTask', 'clipEncoding', + 'library', 'metadataExtraction', 'objectTagging', 'recognizeFaces', diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index 078389c549..69f36eef01 100644 --- a/mobile/openapi/lib/model/asset_response_dto.dart +++ b/mobile/openapi/lib/model/asset_response_dto.dart @@ -22,7 +22,11 @@ class AssetResponseDto { required this.fileModifiedAt, required this.id, required this.isArchived, + required this.isExternal, required this.isFavorite, + required this.isOffline, + required this.isReadOnly, + required this.libraryId, this.livePhotoVideoId, required this.originalFileName, required this.originalPath, @@ -62,8 +66,16 @@ class AssetResponseDto { bool isArchived; + bool isExternal; + bool isFavorite; + bool isOffline; + + bool isReadOnly; + + String libraryId; + String? livePhotoVideoId; String originalFileName; @@ -112,7 +124,11 @@ class AssetResponseDto { other.fileModifiedAt == fileModifiedAt && other.id == id && other.isArchived == isArchived && + other.isExternal == isExternal && other.isFavorite == isFavorite && + other.isOffline == isOffline && + other.isReadOnly == isReadOnly && + other.libraryId == libraryId && other.livePhotoVideoId == livePhotoVideoId && other.originalFileName == originalFileName && other.originalPath == originalPath && @@ -138,7 +154,11 @@ class AssetResponseDto { (fileModifiedAt.hashCode) + (id.hashCode) + (isArchived.hashCode) + + (isExternal.hashCode) + (isFavorite.hashCode) + + (isOffline.hashCode) + + (isReadOnly.hashCode) + + (libraryId.hashCode) + (livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) + (originalFileName.hashCode) + (originalPath.hashCode) + @@ -153,7 +173,7 @@ class AssetResponseDto { (updatedAt.hashCode); @override - String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, livePhotoVideoId=$livePhotoVideoId, originalFileName=$originalFileName, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, smartInfo=$smartInfo, tags=$tags, thumbhash=$thumbhash, type=$type, updatedAt=$updatedAt]'; + String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, id=$id, isArchived=$isArchived, isExternal=$isExternal, isFavorite=$isFavorite, isOffline=$isOffline, isReadOnly=$isReadOnly, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, originalFileName=$originalFileName, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, smartInfo=$smartInfo, tags=$tags, thumbhash=$thumbhash, type=$type, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -170,7 +190,11 @@ class AssetResponseDto { json[r'fileModifiedAt'] = this.fileModifiedAt.toUtc().toIso8601String(); json[r'id'] = this.id; json[r'isArchived'] = this.isArchived; + json[r'isExternal'] = this.isExternal; json[r'isFavorite'] = this.isFavorite; + json[r'isOffline'] = this.isOffline; + json[r'isReadOnly'] = this.isReadOnly; + json[r'libraryId'] = this.libraryId; if (this.livePhotoVideoId != null) { json[r'livePhotoVideoId'] = this.livePhotoVideoId; } else { @@ -219,7 +243,11 @@ class AssetResponseDto { fileModifiedAt: mapDateTime(json, r'fileModifiedAt', '')!, id: mapValueOfType(json, r'id')!, isArchived: mapValueOfType(json, r'isArchived')!, + isExternal: mapValueOfType(json, r'isExternal')!, isFavorite: mapValueOfType(json, r'isFavorite')!, + isOffline: mapValueOfType(json, r'isOffline')!, + isReadOnly: mapValueOfType(json, r'isReadOnly')!, + libraryId: mapValueOfType(json, r'libraryId')!, livePhotoVideoId: mapValueOfType(json, r'livePhotoVideoId'), originalFileName: mapValueOfType(json, r'originalFileName')!, originalPath: mapValueOfType(json, r'originalPath')!, @@ -287,7 +315,11 @@ class AssetResponseDto { 'fileModifiedAt', 'id', 'isArchived', + 'isExternal', 'isFavorite', + 'isOffline', + 'isReadOnly', + 'libraryId', 'originalFileName', 'originalPath', 'ownerId', diff --git a/mobile/openapi/lib/model/create_library_dto.dart b/mobile/openapi/lib/model/create_library_dto.dart new file mode 100644 index 0000000000..ea871e87a7 --- /dev/null +++ b/mobile/openapi/lib/model/create_library_dto.dart @@ -0,0 +1,150 @@ +// +// 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 CreateLibraryDto { + /// Returns a new [CreateLibraryDto] instance. + CreateLibraryDto({ + this.exclusionPatterns = const [], + this.importPaths = const [], + this.isVisible, + this.name, + required this.type, + }); + + List exclusionPatterns; + + List importPaths; + + /// + /// 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. + /// + bool? isVisible; + + /// + /// 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. + /// + String? name; + + LibraryType type; + + @override + bool operator ==(Object other) => identical(this, other) || other is CreateLibraryDto && + other.exclusionPatterns == exclusionPatterns && + other.importPaths == importPaths && + other.isVisible == isVisible && + other.name == name && + other.type == type; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (exclusionPatterns.hashCode) + + (importPaths.hashCode) + + (isVisible == null ? 0 : isVisible!.hashCode) + + (name == null ? 0 : name!.hashCode) + + (type.hashCode); + + @override + String toString() => 'CreateLibraryDto[exclusionPatterns=$exclusionPatterns, importPaths=$importPaths, isVisible=$isVisible, name=$name, type=$type]'; + + Map toJson() { + final json = {}; + json[r'exclusionPatterns'] = this.exclusionPatterns; + json[r'importPaths'] = this.importPaths; + if (this.isVisible != null) { + json[r'isVisible'] = this.isVisible; + } else { + // json[r'isVisible'] = null; + } + if (this.name != null) { + json[r'name'] = this.name; + } else { + // json[r'name'] = null; + } + json[r'type'] = this.type; + return json; + } + + /// Returns a new [CreateLibraryDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static CreateLibraryDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return CreateLibraryDto( + exclusionPatterns: json[r'exclusionPatterns'] is List + ? (json[r'exclusionPatterns'] as List).cast() + : const [], + importPaths: json[r'importPaths'] is List + ? (json[r'importPaths'] as List).cast() + : const [], + isVisible: mapValueOfType(json, r'isVisible'), + name: mapValueOfType(json, r'name'), + type: LibraryType.fromJson(json[r'type'])!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = CreateLibraryDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = CreateLibraryDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of CreateLibraryDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = CreateLibraryDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'type', + }; +} + diff --git a/mobile/openapi/lib/model/import_asset_dto.dart b/mobile/openapi/lib/model/import_asset_dto.dart index e869df9522..66ee3faee2 100644 --- a/mobile/openapi/lib/model/import_asset_dto.dart +++ b/mobile/openapi/lib/model/import_asset_dto.dart @@ -20,9 +20,12 @@ class ImportAssetDto { required this.fileCreatedAt, required this.fileModifiedAt, this.isArchived, + this.isExternal, required this.isFavorite, + this.isOffline, this.isReadOnly = true, this.isVisible, + this.libraryId, this.sidecarPath, }); @@ -52,8 +55,24 @@ class ImportAssetDto { /// bool? isArchived; + /// + /// 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. + /// + bool? isExternal; + bool isFavorite; + /// + /// 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. + /// + bool? isOffline; + bool isReadOnly; /// @@ -64,6 +83,14 @@ class ImportAssetDto { /// bool? isVisible; + /// + /// 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. + /// + String? libraryId; + /// /// 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 @@ -81,9 +108,12 @@ class ImportAssetDto { other.fileCreatedAt == fileCreatedAt && other.fileModifiedAt == fileModifiedAt && other.isArchived == isArchived && + other.isExternal == isExternal && other.isFavorite == isFavorite && + other.isOffline == isOffline && other.isReadOnly == isReadOnly && other.isVisible == isVisible && + other.libraryId == libraryId && other.sidecarPath == sidecarPath; @override @@ -96,13 +126,16 @@ class ImportAssetDto { (fileCreatedAt.hashCode) + (fileModifiedAt.hashCode) + (isArchived == null ? 0 : isArchived!.hashCode) + + (isExternal == null ? 0 : isExternal!.hashCode) + (isFavorite.hashCode) + + (isOffline == null ? 0 : isOffline!.hashCode) + (isReadOnly.hashCode) + (isVisible == null ? 0 : isVisible!.hashCode) + + (libraryId == null ? 0 : libraryId!.hashCode) + (sidecarPath == null ? 0 : sidecarPath!.hashCode); @override - String toString() => 'ImportAssetDto[assetPath=$assetPath, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duration=$duration, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, isArchived=$isArchived, isFavorite=$isFavorite, isReadOnly=$isReadOnly, isVisible=$isVisible, sidecarPath=$sidecarPath]'; + String toString() => 'ImportAssetDto[assetPath=$assetPath, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duration=$duration, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, isArchived=$isArchived, isExternal=$isExternal, isFavorite=$isFavorite, isOffline=$isOffline, isReadOnly=$isReadOnly, isVisible=$isVisible, libraryId=$libraryId, sidecarPath=$sidecarPath]'; Map toJson() { final json = {}; @@ -120,14 +153,29 @@ class ImportAssetDto { json[r'isArchived'] = this.isArchived; } else { // json[r'isArchived'] = null; + } + if (this.isExternal != null) { + json[r'isExternal'] = this.isExternal; + } else { + // json[r'isExternal'] = null; } json[r'isFavorite'] = this.isFavorite; + if (this.isOffline != null) { + json[r'isOffline'] = this.isOffline; + } else { + // json[r'isOffline'] = null; + } json[r'isReadOnly'] = this.isReadOnly; if (this.isVisible != null) { json[r'isVisible'] = this.isVisible; } else { // json[r'isVisible'] = null; } + if (this.libraryId != null) { + json[r'libraryId'] = this.libraryId; + } else { + // json[r'libraryId'] = null; + } if (this.sidecarPath != null) { json[r'sidecarPath'] = this.sidecarPath; } else { @@ -151,9 +199,12 @@ class ImportAssetDto { fileCreatedAt: mapDateTime(json, r'fileCreatedAt', '')!, fileModifiedAt: mapDateTime(json, r'fileModifiedAt', '')!, isArchived: mapValueOfType(json, r'isArchived'), + isExternal: mapValueOfType(json, r'isExternal'), isFavorite: mapValueOfType(json, r'isFavorite')!, + isOffline: mapValueOfType(json, r'isOffline'), isReadOnly: mapValueOfType(json, r'isReadOnly') ?? true, isVisible: mapValueOfType(json, r'isVisible'), + libraryId: mapValueOfType(json, r'libraryId'), sidecarPath: mapValueOfType(json, r'sidecarPath'), ); } diff --git a/mobile/openapi/lib/model/job_name.dart b/mobile/openapi/lib/model/job_name.dart index 984e51ba17..27369551a0 100644 --- a/mobile/openapi/lib/model/job_name.dart +++ b/mobile/openapi/lib/model/job_name.dart @@ -33,6 +33,7 @@ class JobName { static const storageTemplateMigration = JobName._(r'storageTemplateMigration'); static const search = JobName._(r'search'); static const sidecar = JobName._(r'sidecar'); + static const library_ = JobName._(r'library'); /// List of all possible values in this [enum][JobName]. static const values = [ @@ -46,6 +47,7 @@ class JobName { storageTemplateMigration, search, sidecar, + library_, ]; static JobName? fromJson(dynamic value) => JobNameTypeTransformer().decode(value); @@ -94,6 +96,7 @@ class JobNameTypeTransformer { case r'storageTemplateMigration': return JobName.storageTemplateMigration; case r'search': return JobName.search; case r'sidecar': return JobName.sidecar; + case r'library': return JobName.library_; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); diff --git a/mobile/openapi/lib/model/library_response_dto.dart b/mobile/openapi/lib/model/library_response_dto.dart new file mode 100644 index 0000000000..ffbfa35679 --- /dev/null +++ b/mobile/openapi/lib/model/library_response_dto.dart @@ -0,0 +1,178 @@ +// +// 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 LibraryResponseDto { + /// Returns a new [LibraryResponseDto] instance. + LibraryResponseDto({ + required this.assetCount, + required this.createdAt, + this.exclusionPatterns = const [], + required this.id, + this.importPaths = const [], + required this.name, + required this.ownerId, + required this.refreshedAt, + required this.type, + required this.updatedAt, + }); + + int assetCount; + + DateTime createdAt; + + List exclusionPatterns; + + String id; + + List importPaths; + + String name; + + String ownerId; + + DateTime? refreshedAt; + + LibraryType type; + + DateTime updatedAt; + + @override + bool operator ==(Object other) => identical(this, other) || other is LibraryResponseDto && + other.assetCount == assetCount && + other.createdAt == createdAt && + other.exclusionPatterns == exclusionPatterns && + other.id == id && + other.importPaths == importPaths && + other.name == name && + other.ownerId == ownerId && + other.refreshedAt == refreshedAt && + other.type == type && + other.updatedAt == updatedAt; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetCount.hashCode) + + (createdAt.hashCode) + + (exclusionPatterns.hashCode) + + (id.hashCode) + + (importPaths.hashCode) + + (name.hashCode) + + (ownerId.hashCode) + + (refreshedAt == null ? 0 : refreshedAt!.hashCode) + + (type.hashCode) + + (updatedAt.hashCode); + + @override + String toString() => 'LibraryResponseDto[assetCount=$assetCount, createdAt=$createdAt, exclusionPatterns=$exclusionPatterns, id=$id, importPaths=$importPaths, name=$name, ownerId=$ownerId, refreshedAt=$refreshedAt, type=$type, updatedAt=$updatedAt]'; + + Map toJson() { + final json = {}; + json[r'assetCount'] = this.assetCount; + json[r'createdAt'] = this.createdAt.toUtc().toIso8601String(); + json[r'exclusionPatterns'] = this.exclusionPatterns; + json[r'id'] = this.id; + json[r'importPaths'] = this.importPaths; + json[r'name'] = this.name; + json[r'ownerId'] = this.ownerId; + if (this.refreshedAt != null) { + json[r'refreshedAt'] = this.refreshedAt!.toUtc().toIso8601String(); + } else { + // json[r'refreshedAt'] = null; + } + json[r'type'] = this.type; + json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String(); + return json; + } + + /// Returns a new [LibraryResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static LibraryResponseDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return LibraryResponseDto( + assetCount: mapValueOfType(json, r'assetCount')!, + createdAt: mapDateTime(json, r'createdAt', '')!, + exclusionPatterns: json[r'exclusionPatterns'] is List + ? (json[r'exclusionPatterns'] as List).cast() + : const [], + id: mapValueOfType(json, r'id')!, + importPaths: json[r'importPaths'] is List + ? (json[r'importPaths'] as List).cast() + : const [], + name: mapValueOfType(json, r'name')!, + ownerId: mapValueOfType(json, r'ownerId')!, + refreshedAt: mapDateTime(json, r'refreshedAt', ''), + type: LibraryType.fromJson(json[r'type'])!, + updatedAt: mapDateTime(json, r'updatedAt', '')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = LibraryResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = LibraryResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of LibraryResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = LibraryResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetCount', + 'createdAt', + 'exclusionPatterns', + 'id', + 'importPaths', + 'name', + 'ownerId', + 'refreshedAt', + 'type', + 'updatedAt', + }; +} + diff --git a/mobile/openapi/lib/model/library_stats_response_dto.dart b/mobile/openapi/lib/model/library_stats_response_dto.dart new file mode 100644 index 0000000000..2f389d414d --- /dev/null +++ b/mobile/openapi/lib/model/library_stats_response_dto.dart @@ -0,0 +1,122 @@ +// +// 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 LibraryStatsResponseDto { + /// Returns a new [LibraryStatsResponseDto] instance. + LibraryStatsResponseDto({ + this.photos = 0, + this.total = 0, + this.usage = 0, + this.videos = 0, + }); + + int photos; + + int total; + + int usage; + + int videos; + + @override + bool operator ==(Object other) => identical(this, other) || other is LibraryStatsResponseDto && + other.photos == photos && + other.total == total && + other.usage == usage && + other.videos == videos; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (photos.hashCode) + + (total.hashCode) + + (usage.hashCode) + + (videos.hashCode); + + @override + String toString() => 'LibraryStatsResponseDto[photos=$photos, total=$total, usage=$usage, videos=$videos]'; + + Map toJson() { + final json = {}; + json[r'photos'] = this.photos; + json[r'total'] = this.total; + json[r'usage'] = this.usage; + json[r'videos'] = this.videos; + return json; + } + + /// Returns a new [LibraryStatsResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static LibraryStatsResponseDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return LibraryStatsResponseDto( + photos: mapValueOfType(json, r'photos')!, + total: mapValueOfType(json, r'total')!, + usage: mapValueOfType(json, r'usage')!, + videos: mapValueOfType(json, r'videos')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = LibraryStatsResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = LibraryStatsResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of LibraryStatsResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = LibraryStatsResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'photos', + 'total', + 'usage', + 'videos', + }; +} + diff --git a/mobile/openapi/lib/model/library_type.dart b/mobile/openapi/lib/model/library_type.dart new file mode 100644 index 0000000000..18b0e18d44 --- /dev/null +++ b/mobile/openapi/lib/model/library_type.dart @@ -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 LibraryType { + /// Instantiate a new enum with the provided [value]. + const LibraryType._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const UPLOAD = LibraryType._(r'UPLOAD'); + static const EXTERNAL = LibraryType._(r'EXTERNAL'); + + /// List of all possible values in this [enum][LibraryType]. + static const values = [ + UPLOAD, + EXTERNAL, + ]; + + static LibraryType? fromJson(dynamic value) => LibraryTypeTypeTransformer().decode(value); + + static List? listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = LibraryType.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [LibraryType] to String, +/// and [decode] dynamic data back to [LibraryType]. +class LibraryTypeTypeTransformer { + factory LibraryTypeTypeTransformer() => _instance ??= const LibraryTypeTypeTransformer._(); + + const LibraryTypeTypeTransformer._(); + + String encode(LibraryType data) => data.value; + + /// Decodes a [dynamic value][data] to a LibraryType. + /// + /// 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. + LibraryType? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'UPLOAD': return LibraryType.UPLOAD; + case r'EXTERNAL': return LibraryType.EXTERNAL; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [LibraryTypeTypeTransformer] instance. + static LibraryTypeTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/scan_library_dto.dart b/mobile/openapi/lib/model/scan_library_dto.dart new file mode 100644 index 0000000000..ce84d54341 --- /dev/null +++ b/mobile/openapi/lib/model/scan_library_dto.dart @@ -0,0 +1,114 @@ +// +// 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 ScanLibraryDto { + /// Returns a new [ScanLibraryDto] instance. + ScanLibraryDto({ + this.refreshAllFiles = false, + this.refreshModifiedFiles, + }); + + bool refreshAllFiles; + + /// + /// 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. + /// + bool? refreshModifiedFiles; + + @override + bool operator ==(Object other) => identical(this, other) || other is ScanLibraryDto && + other.refreshAllFiles == refreshAllFiles && + other.refreshModifiedFiles == refreshModifiedFiles; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (refreshAllFiles.hashCode) + + (refreshModifiedFiles == null ? 0 : refreshModifiedFiles!.hashCode); + + @override + String toString() => 'ScanLibraryDto[refreshAllFiles=$refreshAllFiles, refreshModifiedFiles=$refreshModifiedFiles]'; + + Map toJson() { + final json = {}; + json[r'refreshAllFiles'] = this.refreshAllFiles; + if (this.refreshModifiedFiles != null) { + json[r'refreshModifiedFiles'] = this.refreshModifiedFiles; + } else { + // json[r'refreshModifiedFiles'] = null; + } + return json; + } + + /// Returns a new [ScanLibraryDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static ScanLibraryDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return ScanLibraryDto( + refreshAllFiles: mapValueOfType(json, r'refreshAllFiles') ?? false, + refreshModifiedFiles: mapValueOfType(json, r'refreshModifiedFiles'), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = ScanLibraryDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = ScanLibraryDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of ScanLibraryDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = ScanLibraryDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + }; +} + diff --git a/mobile/openapi/lib/model/system_config_job_dto.dart b/mobile/openapi/lib/model/system_config_job_dto.dart index ca3dab9e6d..a2f1695713 100644 --- a/mobile/openapi/lib/model/system_config_job_dto.dart +++ b/mobile/openapi/lib/model/system_config_job_dto.dart @@ -15,6 +15,7 @@ class SystemConfigJobDto { SystemConfigJobDto({ required this.backgroundTask, required this.clipEncoding, + required this.library_, required this.metadataExtraction, required this.objectTagging, required this.recognizeFaces, @@ -29,6 +30,8 @@ class SystemConfigJobDto { JobSettingsDto clipEncoding; + JobSettingsDto library_; + JobSettingsDto metadataExtraction; JobSettingsDto objectTagging; @@ -49,6 +52,7 @@ class SystemConfigJobDto { bool operator ==(Object other) => identical(this, other) || other is SystemConfigJobDto && other.backgroundTask == backgroundTask && other.clipEncoding == clipEncoding && + other.library_ == library_ && other.metadataExtraction == metadataExtraction && other.objectTagging == objectTagging && other.recognizeFaces == recognizeFaces && @@ -63,6 +67,7 @@ class SystemConfigJobDto { // ignore: unnecessary_parenthesis (backgroundTask.hashCode) + (clipEncoding.hashCode) + + (library_.hashCode) + (metadataExtraction.hashCode) + (objectTagging.hashCode) + (recognizeFaces.hashCode) + @@ -73,12 +78,13 @@ class SystemConfigJobDto { (videoConversion.hashCode); @override - String toString() => 'SystemConfigJobDto[backgroundTask=$backgroundTask, clipEncoding=$clipEncoding, metadataExtraction=$metadataExtraction, objectTagging=$objectTagging, recognizeFaces=$recognizeFaces, search=$search, sidecar=$sidecar, storageTemplateMigration=$storageTemplateMigration, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion]'; + String toString() => 'SystemConfigJobDto[backgroundTask=$backgroundTask, clipEncoding=$clipEncoding, library_=$library_, metadataExtraction=$metadataExtraction, objectTagging=$objectTagging, recognizeFaces=$recognizeFaces, search=$search, sidecar=$sidecar, storageTemplateMigration=$storageTemplateMigration, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion]'; Map toJson() { final json = {}; json[r'backgroundTask'] = this.backgroundTask; json[r'clipEncoding'] = this.clipEncoding; + json[r'library'] = this.library_; json[r'metadataExtraction'] = this.metadataExtraction; json[r'objectTagging'] = this.objectTagging; json[r'recognizeFaces'] = this.recognizeFaces; @@ -100,6 +106,7 @@ class SystemConfigJobDto { return SystemConfigJobDto( backgroundTask: JobSettingsDto.fromJson(json[r'backgroundTask'])!, clipEncoding: JobSettingsDto.fromJson(json[r'clipEncoding'])!, + library_: JobSettingsDto.fromJson(json[r'library'])!, metadataExtraction: JobSettingsDto.fromJson(json[r'metadataExtraction'])!, objectTagging: JobSettingsDto.fromJson(json[r'objectTagging'])!, recognizeFaces: JobSettingsDto.fromJson(json[r'recognizeFaces'])!, @@ -157,6 +164,7 @@ class SystemConfigJobDto { static const requiredKeys = { 'backgroundTask', 'clipEncoding', + 'library', 'metadataExtraction', 'objectTagging', 'recognizeFaces', diff --git a/mobile/openapi/lib/model/update_library_dto.dart b/mobile/openapi/lib/model/update_library_dto.dart new file mode 100644 index 0000000000..4bceea393c --- /dev/null +++ b/mobile/openapi/lib/model/update_library_dto.dart @@ -0,0 +1,142 @@ +// +// 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 UpdateLibraryDto { + /// Returns a new [UpdateLibraryDto] instance. + UpdateLibraryDto({ + this.exclusionPatterns = const [], + this.importPaths = const [], + this.isVisible, + this.name, + }); + + List exclusionPatterns; + + List importPaths; + + /// + /// 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. + /// + bool? isVisible; + + /// + /// 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. + /// + String? name; + + @override + bool operator ==(Object other) => identical(this, other) || other is UpdateLibraryDto && + other.exclusionPatterns == exclusionPatterns && + other.importPaths == importPaths && + other.isVisible == isVisible && + other.name == name; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (exclusionPatterns.hashCode) + + (importPaths.hashCode) + + (isVisible == null ? 0 : isVisible!.hashCode) + + (name == null ? 0 : name!.hashCode); + + @override + String toString() => 'UpdateLibraryDto[exclusionPatterns=$exclusionPatterns, importPaths=$importPaths, isVisible=$isVisible, name=$name]'; + + Map toJson() { + final json = {}; + json[r'exclusionPatterns'] = this.exclusionPatterns; + json[r'importPaths'] = this.importPaths; + if (this.isVisible != null) { + json[r'isVisible'] = this.isVisible; + } else { + // json[r'isVisible'] = null; + } + if (this.name != null) { + json[r'name'] = this.name; + } else { + // json[r'name'] = null; + } + return json; + } + + /// Returns a new [UpdateLibraryDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static UpdateLibraryDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return UpdateLibraryDto( + exclusionPatterns: json[r'exclusionPatterns'] is List + ? (json[r'exclusionPatterns'] as List).cast() + : const [], + importPaths: json[r'importPaths'] is List + ? (json[r'importPaths'] as List).cast() + : const [], + isVisible: mapValueOfType(json, r'isVisible'), + name: mapValueOfType(json, r'name'), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = UpdateLibraryDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = UpdateLibraryDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of UpdateLibraryDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = UpdateLibraryDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + }; +} + diff --git a/mobile/openapi/test/all_job_status_response_dto_test.dart b/mobile/openapi/test/all_job_status_response_dto_test.dart index e981b1a36d..68a5983e70 100644 --- a/mobile/openapi/test/all_job_status_response_dto_test.dart +++ b/mobile/openapi/test/all_job_status_response_dto_test.dart @@ -26,6 +26,11 @@ void main() { // TODO }); + // JobStatusDto library_ + test('to test the property `library_`', () async { + // TODO + }); + // JobStatusDto metadataExtraction test('to test the property `metadataExtraction`', () async { // TODO diff --git a/mobile/openapi/test/asset_api_test.dart b/mobile/openapi/test/asset_api_test.dart index ecbfdf4c29..d374f212ef 100644 --- a/mobile/openapi/test/asset_api_test.dart +++ b/mobile/openapi/test/asset_api_test.dart @@ -154,7 +154,7 @@ void main() { // TODO }); - //Future uploadFile(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, bool isFavorite, { String key, String duration, bool isArchived, bool isReadOnly, bool isVisible, MultipartFile livePhotoData, MultipartFile sidecarData }) async + //Future uploadFile(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, bool isFavorite, { String key, String duration, bool isArchived, bool isExternal, bool isOffline, bool isReadOnly, bool isVisible, String libraryId, MultipartFile livePhotoData, MultipartFile sidecarData }) async test('test uploadFile', () async { // TODO }); diff --git a/mobile/openapi/test/asset_response_dto_test.dart b/mobile/openapi/test/asset_response_dto_test.dart index d0985f429b..fdbdb97ece 100644 --- a/mobile/openapi/test/asset_response_dto_test.dart +++ b/mobile/openapi/test/asset_response_dto_test.dart @@ -62,11 +62,31 @@ void main() { // TODO }); + // bool isExternal + test('to test the property `isExternal`', () async { + // TODO + }); + // bool isFavorite test('to test the property `isFavorite`', () async { // TODO }); + // bool isOffline + test('to test the property `isOffline`', () async { + // TODO + }); + + // bool isReadOnly + test('to test the property `isReadOnly`', () async { + // TODO + }); + + // String libraryId + test('to test the property `libraryId`', () async { + // TODO + }); + // String livePhotoVideoId test('to test the property `livePhotoVideoId`', () async { // TODO diff --git a/mobile/openapi/test/create_library_dto_test.dart b/mobile/openapi/test/create_library_dto_test.dart new file mode 100644 index 0000000000..eecccbcf75 --- /dev/null +++ b/mobile/openapi/test/create_library_dto_test.dart @@ -0,0 +1,47 @@ +// +// 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 CreateLibraryDto +void main() { + // final instance = CreateLibraryDto(); + + group('test CreateLibraryDto', () { + // List exclusionPatterns (default value: const []) + test('to test the property `exclusionPatterns`', () async { + // TODO + }); + + // List importPaths (default value: const []) + test('to test the property `importPaths`', () async { + // TODO + }); + + // bool isVisible + test('to test the property `isVisible`', () async { + // TODO + }); + + // String name + test('to test the property `name`', () async { + // TODO + }); + + // LibraryType type + test('to test the property `type`', () async { + // TODO + }); + + + }); + +} diff --git a/mobile/openapi/test/import_asset_dto_test.dart b/mobile/openapi/test/import_asset_dto_test.dart index 213a75265b..94e2a3bc46 100644 --- a/mobile/openapi/test/import_asset_dto_test.dart +++ b/mobile/openapi/test/import_asset_dto_test.dart @@ -51,11 +51,21 @@ void main() { // TODO }); + // bool isExternal + test('to test the property `isExternal`', () async { + // TODO + }); + // bool isFavorite test('to test the property `isFavorite`', () async { // TODO }); + // bool isOffline + test('to test the property `isOffline`', () async { + // TODO + }); + // bool isReadOnly (default value: true) test('to test the property `isReadOnly`', () async { // TODO @@ -66,6 +76,11 @@ void main() { // TODO }); + // String libraryId + test('to test the property `libraryId`', () async { + // TODO + }); + // String sidecarPath test('to test the property `sidecarPath`', () async { // TODO diff --git a/mobile/openapi/test/library_api_test.dart b/mobile/openapi/test/library_api_test.dart new file mode 100644 index 0000000000..485ecb4b4f --- /dev/null +++ b/mobile/openapi/test/library_api_test.dart @@ -0,0 +1,61 @@ +// +// 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 LibraryApi +void main() { + // final instance = LibraryApi(); + + group('tests for LibraryApi', () { + //Future createLibrary(CreateLibraryDto createLibraryDto) async + test('test createLibrary', () async { + // TODO + }); + + //Future deleteLibrary(String id) async + test('test deleteLibrary', () async { + // TODO + }); + + //Future> getAllForUser() async + test('test getAllForUser', () async { + // TODO + }); + + //Future getLibraryInfo(String id) async + test('test getLibraryInfo', () async { + // TODO + }); + + //Future getLibraryStatistics(String id) async + test('test getLibraryStatistics', () async { + // TODO + }); + + //Future removeOfflineFiles(String id) async + test('test removeOfflineFiles', () async { + // TODO + }); + + //Future scanLibrary(String id, ScanLibraryDto scanLibraryDto) async + test('test scanLibrary', () async { + // TODO + }); + + //Future updateLibrary(String id, UpdateLibraryDto updateLibraryDto) async + test('test updateLibrary', () async { + // TODO + }); + + }); +} diff --git a/mobile/openapi/test/library_response_dto_test.dart b/mobile/openapi/test/library_response_dto_test.dart new file mode 100644 index 0000000000..9fd196d1b0 --- /dev/null +++ b/mobile/openapi/test/library_response_dto_test.dart @@ -0,0 +1,72 @@ +// +// 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 LibraryResponseDto +void main() { + // final instance = LibraryResponseDto(); + + group('test LibraryResponseDto', () { + // int assetCount + test('to test the property `assetCount`', () async { + // TODO + }); + + // DateTime createdAt + test('to test the property `createdAt`', () async { + // TODO + }); + + // List exclusionPatterns (default value: const []) + test('to test the property `exclusionPatterns`', () async { + // TODO + }); + + // String id + test('to test the property `id`', () async { + // TODO + }); + + // List importPaths (default value: const []) + test('to test the property `importPaths`', () async { + // TODO + }); + + // String name + test('to test the property `name`', () async { + // TODO + }); + + // String ownerId + test('to test the property `ownerId`', () async { + // TODO + }); + + // DateTime refreshedAt + test('to test the property `refreshedAt`', () async { + // TODO + }); + + // LibraryType type + test('to test the property `type`', () async { + // TODO + }); + + // DateTime updatedAt + test('to test the property `updatedAt`', () async { + // TODO + }); + + + }); + +} diff --git a/mobile/openapi/test/library_stats_response_dto_test.dart b/mobile/openapi/test/library_stats_response_dto_test.dart new file mode 100644 index 0000000000..91e9bb7040 --- /dev/null +++ b/mobile/openapi/test/library_stats_response_dto_test.dart @@ -0,0 +1,42 @@ +// +// 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 LibraryStatsResponseDto +void main() { + // final instance = LibraryStatsResponseDto(); + + group('test LibraryStatsResponseDto', () { + // int photos (default value: 0) + test('to test the property `photos`', () async { + // TODO + }); + + // int total (default value: 0) + test('to test the property `total`', () async { + // TODO + }); + + // int usage (default value: 0) + test('to test the property `usage`', () async { + // TODO + }); + + // int videos (default value: 0) + test('to test the property `videos`', () async { + // TODO + }); + + + }); + +} diff --git a/mobile/openapi/test/library_type_test.dart b/mobile/openapi/test/library_type_test.dart new file mode 100644 index 0000000000..991e21c9e2 --- /dev/null +++ b/mobile/openapi/test/library_type_test.dart @@ -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 LibraryType +void main() { + + group('test LibraryType', () { + + }); + +} diff --git a/mobile/openapi/test/scan_library_dto_test.dart b/mobile/openapi/test/scan_library_dto_test.dart new file mode 100644 index 0000000000..975a6d757f --- /dev/null +++ b/mobile/openapi/test/scan_library_dto_test.dart @@ -0,0 +1,32 @@ +// +// 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 ScanLibraryDto +void main() { + // final instance = ScanLibraryDto(); + + group('test ScanLibraryDto', () { + // bool refreshAllFiles (default value: false) + test('to test the property `refreshAllFiles`', () async { + // TODO + }); + + // bool refreshModifiedFiles + test('to test the property `refreshModifiedFiles`', () async { + // TODO + }); + + + }); + +} diff --git a/mobile/openapi/test/system_config_job_dto_test.dart b/mobile/openapi/test/system_config_job_dto_test.dart index 0dbc6feee4..a5557662f9 100644 --- a/mobile/openapi/test/system_config_job_dto_test.dart +++ b/mobile/openapi/test/system_config_job_dto_test.dart @@ -26,6 +26,11 @@ void main() { // TODO }); + // JobSettingsDto library_ + test('to test the property `library_`', () async { + // TODO + }); + // JobSettingsDto metadataExtraction test('to test the property `metadataExtraction`', () async { // TODO diff --git a/mobile/openapi/test/update_library_dto_test.dart b/mobile/openapi/test/update_library_dto_test.dart new file mode 100644 index 0000000000..222eb333bc --- /dev/null +++ b/mobile/openapi/test/update_library_dto_test.dart @@ -0,0 +1,42 @@ +// +// 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 UpdateLibraryDto +void main() { + // final instance = UpdateLibraryDto(); + + group('test UpdateLibraryDto', () { + // List exclusionPatterns (default value: const []) + test('to test the property `exclusionPatterns`', () async { + // TODO + }); + + // List importPaths (default value: const []) + test('to test the property `importPaths`', () async { + // TODO + }); + + // bool isVisible + test('to test the property `isVisible`', () async { + // TODO + }); + + // String name + test('to test the property `name`', () async { + // TODO + }); + + + }); + +} diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 622ff4fd43..4ec5a8518a 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -2476,6 +2476,328 @@ ] } }, + "/library": { + "get": { + "operationId": "getAllForUser", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/LibraryResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Library" + ] + }, + "post": { + "operationId": "createLibrary", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateLibraryDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LibraryResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Library" + ] + } + }, + "/library/{id}": { + "delete": { + "operationId": "deleteLibrary", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Library" + ] + }, + "get": { + "operationId": "getLibraryInfo", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LibraryResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Library" + ] + }, + "put": { + "operationId": "updateLibrary", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateLibraryDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LibraryResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Library" + ] + } + }, + "/library/{id}/removeOffline": { + "post": { + "operationId": "removeOfflineFiles", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Library" + ] + } + }, + "/library/{id}/scan": { + "post": { + "operationId": "scanLibrary", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScanLibraryDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Library" + ] + } + }, + "/library/{id}/statistics": { + "get": { + "operationId": "getLibraryStatistics", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LibraryStatsResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Library" + ] + } + }, "/oauth/authorize": { "post": { "operationId": "authorizeOAuth", @@ -4971,6 +5293,9 @@ "clipEncoding": { "$ref": "#/components/schemas/JobStatusDto" }, + "library": { + "$ref": "#/components/schemas/JobStatusDto" + }, "metadataExtraction": { "$ref": "#/components/schemas/JobStatusDto" }, @@ -5006,7 +5331,8 @@ "backgroundTask", "search", "recognizeFaces", - "sidecar" + "sidecar", + "library" ], "type": "object" }, @@ -5216,9 +5542,21 @@ "isArchived": { "type": "boolean" }, + "isExternal": { + "type": "boolean" + }, "isFavorite": { "type": "boolean" }, + "isOffline": { + "type": "boolean" + }, + "isReadOnly": { + "type": "boolean" + }, + "libraryId": { + "type": "string" + }, "livePhotoVideoId": { "nullable": true, "type": "string" @@ -5272,6 +5610,7 @@ "deviceAssetId", "deviceId", "ownerId", + "libraryId", "originalPath", "originalFileName", "resized", @@ -5281,6 +5620,9 @@ "updatedAt", "isFavorite", "isArchived", + "isOffline", + "isExternal", + "isReadOnly", "duration", "checksum" ], @@ -5607,16 +5949,25 @@ "isArchived": { "type": "boolean" }, + "isExternal": { + "type": "boolean" + }, "isFavorite": { "type": "boolean" }, + "isOffline": { + "type": "boolean" + }, "isReadOnly": { - "default": false, "type": "boolean" }, "isVisible": { "type": "boolean" }, + "libraryId": { + "format": "uuid", + "type": "string" + }, "livePhotoData": { "format": "binary", "type": "string" @@ -5636,6 +5987,35 @@ ], "type": "object" }, + "CreateLibraryDto": { + "properties": { + "exclusionPatterns": { + "items": { + "type": "string" + }, + "type": "array" + }, + "importPaths": { + "items": { + "type": "string" + }, + "type": "array" + }, + "isVisible": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/LibraryType" + } + }, + "required": [ + "type" + ], + "type": "object" + }, "CreateProfileImageDto": { "properties": { "file": { @@ -6012,9 +6392,15 @@ "isArchived": { "type": "boolean" }, + "isExternal": { + "type": "boolean" + }, "isFavorite": { "type": "boolean" }, + "isOffline": { + "type": "boolean" + }, "isReadOnly": { "default": true, "type": "boolean" @@ -6022,6 +6408,10 @@ "isVisible": { "type": "boolean" }, + "libraryId": { + "format": "uuid", + "type": "string" + }, "sidecarPath": { "type": "string" } @@ -6102,7 +6492,8 @@ "backgroundTask", "storageTemplateMigration", "search", - "sidecar" + "sidecar", + "library" ], "type": "string" }, @@ -6132,6 +6523,98 @@ ], "type": "object" }, + "LibraryResponseDto": { + "properties": { + "assetCount": { + "type": "integer" + }, + "createdAt": { + "format": "date-time", + "type": "string" + }, + "exclusionPatterns": { + "items": { + "type": "string" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "importPaths": { + "items": { + "type": "string" + }, + "type": "array" + }, + "name": { + "type": "string" + }, + "ownerId": { + "type": "string" + }, + "refreshedAt": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/LibraryType" + }, + "updatedAt": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "type", + "assetCount", + "id", + "ownerId", + "name", + "importPaths", + "exclusionPatterns", + "createdAt", + "updatedAt", + "refreshedAt" + ], + "type": "object" + }, + "LibraryStatsResponseDto": { + "properties": { + "photos": { + "default": 0, + "type": "integer" + }, + "total": { + "default": 0, + "type": "integer" + }, + "usage": { + "default": 0, + "format": "int64", + "type": "integer" + }, + "videos": { + "default": 0, + "type": "integer" + } + }, + "required": [ + "photos", + "videos", + "total", + "usage" + ], + "type": "object" + }, + "LibraryType": { + "enum": [ + "UPLOAD", + "EXTERNAL" + ], + "type": "string" + }, "LoginCredentialDto": { "properties": { "email": { @@ -6493,6 +6976,18 @@ ], "type": "object" }, + "ScanLibraryDto": { + "properties": { + "refreshAllFiles": { + "default": false, + "type": "boolean" + }, + "refreshModifiedFiles": { + "type": "boolean" + } + }, + "type": "object" + }, "SearchAlbumResponseDto": { "properties": { "count": { @@ -7148,6 +7643,9 @@ "clipEncoding": { "$ref": "#/components/schemas/JobSettingsDto" }, + "library": { + "$ref": "#/components/schemas/JobSettingsDto" + }, "metadataExtraction": { "$ref": "#/components/schemas/JobSettingsDto" }, @@ -7183,7 +7681,8 @@ "backgroundTask", "search", "recognizeFaces", - "sidecar" + "sidecar", + "library" ], "type": "object" }, @@ -7497,6 +7996,29 @@ }, "type": "object" }, + "UpdateLibraryDto": { + "properties": { + "exclusionPatterns": { + "items": { + "type": "string" + }, + "type": "array" + }, + "importPaths": { + "items": { + "type": "string" + }, + "type": "array" + }, + "isVisible": { + "type": "boolean" + }, + "name": { + "type": "string" + } + }, + "type": "object" + }, "UpdateTagDto": { "properties": { "name": { diff --git a/server/package-lock.json b/server/package-lock.json index ae4d49d3cc..3e2135ac2c 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -32,6 +32,7 @@ "exiftool-vendored.pl": "^12.62.0", "fluent-ffmpeg": "^2.1.2", "geo-tz": "^7.0.7", + "glob": "^10.3.3", "handlebars": "^4.7.8", "i18n-iso-countries": "^7.6.0", "immich": "^0.41.0", @@ -73,6 +74,7 @@ "@types/jest": "29.5.4", "@types/jest-when": "^3.5.2", "@types/lodash": "^4.14.197", + "@types/mock-fs": "^4.13.1", "@types/multer": "^1.4.7", "@types/mv": "^2.1.2", "@types/node": "^20.5.7", @@ -87,6 +89,7 @@ "eslint-plugin-prettier": "^5.0.0", "jest": "^29.6.4", "jest-when": "^3.6.0", + "mock-fs": "^5.2.0", "prettier": "^3.0.2", "prettier-plugin-organize-imports": "^3.2.3", "rimraf": "^5.0.1", @@ -1046,7 +1049,6 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", @@ -1063,7 +1065,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", - "dev": true, "engines": { "node": ">=12" }, @@ -1075,7 +1076,6 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, "engines": { "node": ">=12" }, @@ -1086,14 +1086,12 @@ "node_modules/@isaacs/cliui/node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" }, "node_modules/@isaacs/cliui/node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", @@ -1110,7 +1108,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, "dependencies": { "ansi-regex": "^6.0.1" }, @@ -1125,7 +1122,6 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", @@ -1310,21 +1306,6 @@ } } }, - "node_modules/@jest/environment": { - "version": "29.6.4", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.6.4.tgz", - "integrity": "sha512-sQ0SULEjA1XUTHmkBRl7A1dyITM9yb1yb3ZNKPX3KlTd6IG7mWUe3e2yfExtC2Zz1Q+mMckOLHmL/qLiuQJrBQ==", - "dev": true, - "dependencies": { - "@jest/fake-timers": "^29.6.4", - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-mock": "^29.6.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, "node_modules/@jest/expect": { "version": "29.6.4", "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.6.4.tgz", @@ -1350,7 +1331,37 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@jest/fake-timers": { + "node_modules/@jest/globals": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.6.4.tgz", + "integrity": "sha512-wVIn5bdtjlChhXAzVXavcY/3PEjf4VqM174BM3eGL5kMxLiZD5CLnbmkEyA1Dwh9q8XjP6E8RwjBsY/iCWrWsA==", + "dev": true, + "dependencies": { + "@jest/environment": "^29.6.4", + "@jest/expect": "^29.6.4", + "@jest/types": "^29.6.3", + "jest-mock": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals/node_modules/@jest/environment": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.6.4.tgz", + "integrity": "sha512-sQ0SULEjA1XUTHmkBRl7A1dyITM9yb1yb3ZNKPX3KlTd6IG7mWUe3e2yfExtC2Zz1Q+mMckOLHmL/qLiuQJrBQ==", + "dev": true, + "dependencies": { + "@jest/fake-timers": "^29.6.4", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals/node_modules/@jest/fake-timers": { "version": "29.6.4", "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.6.4.tgz", "integrity": "sha512-6UkCwzoBK60edXIIWb0/KWkuj7R7Qq91vVInOe3De6DSpaEiqjKcJw4F7XUet24Wupahj9J6PlR09JqJ5ySDHw==", @@ -1367,16 +1378,15 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@jest/globals": { - "version": "29.6.4", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.6.4.tgz", - "integrity": "sha512-wVIn5bdtjlChhXAzVXavcY/3PEjf4VqM174BM3eGL5kMxLiZD5CLnbmkEyA1Dwh9q8XjP6E8RwjBsY/iCWrWsA==", + "node_modules/@jest/globals/node_modules/jest-mock": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.6.3.tgz", + "integrity": "sha512-Z7Gs/mOyTSR4yPsaZ72a/MtuK6RnC3JYqWONe48oLaoEcYwEDxqvbXz85G4SJrm2Z5Ar9zp6MiHF4AlFlRM4Pg==", "dev": true, "dependencies": { - "@jest/environment": "^29.6.4", - "@jest/expect": "^29.6.4", "@jest/types": "^29.6.3", - "jest-mock": "^29.6.3" + "@types/node": "*", + "jest-util": "^29.6.3" }, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" @@ -1425,6 +1435,26 @@ } } }, + "node_modules/@jest/reporters/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@jest/reporters/node_modules/jest-worker": { "version": "29.6.4", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.6.4.tgz", @@ -1534,7 +1564,7 @@ "write-file-atomic": "^4.0.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, "node_modules/@jest/types": { @@ -1645,6 +1675,25 @@ "node-pre-gyp": "bin/node-pre-gyp" } }, + "node_modules/@mapbox/node-pre-gyp/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@mapbox/node-pre-gyp/node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -2614,7 +2663,27 @@ "optional": true, "peer": true, "engines": { - "node": ">=10.0.0" + "node": ">= 0.6" + } + }, + "node_modules/@openapitools/openapi-generator-cli/node_modules/glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/@openapitools/openapi-generator-cli/node_modules/rxjs": { @@ -2678,7 +2747,6 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, "optional": true, "engines": { "node": ">=14" @@ -3435,6 +3503,15 @@ "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==", "dev": true }, + "node_modules/@types/mock-fs": { + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/@types/mock-fs/-/mock-fs-4.13.1.tgz", + "integrity": "sha512-m6nFAJ3lBSnqbvDZioawRvpLXSaPyn52Srf7OfzjubYbYX8MTUdIgDxQl0wEapm4m/pNYSd9TXocpQ0TvZFlYA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/multer": { "version": "1.4.7", "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.7.tgz", @@ -4186,6 +4263,25 @@ "node": ">= 6" } }, + "node_modules/archiver-utils/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/archiver-utils/node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", @@ -4233,6 +4329,25 @@ "node": ">= 10" } }, + "node_modules/archiver/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/are-we-there-yet": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", @@ -5767,7 +5882,6 @@ "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -6252,8 +6366,7 @@ "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" }, "node_modules/easy-table": { "version": "1.1.0", @@ -7208,6 +7321,26 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/flat-cache/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/flat-cache/node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -7275,7 +7408,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==", - "dev": true, "dependencies": { "cross-spawn": "^7.0.0", "signal-exit": "^4.0.1" @@ -7291,7 +7423,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, "engines": { "node": ">=14" }, @@ -7589,19 +7720,21 @@ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" }, "node_modules/glob": { - "version": "7.1.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", - "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "version": "10.3.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.3.tgz", + "integrity": "sha512-92vPiMb/iqpmEgsOoIDvTjc50wf9CCCvMzsi6W0JLPeUKE8TWP1a73PgqSrqy7iAZxaSD1YdzU7QZR5LF51MJw==", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "foreground-child": "^3.1.0", + "jackspeak": "^2.0.3", + "minimatch": "^9.0.1", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", + "path-scurry": "^1.10.1" + }, + "bin": { + "glob": "dist/cjs/src/bin.js" }, "engines": { - "node": "*" + "node": ">=16 || 14 >=14.17" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -7625,6 +7758,28 @@ "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", "dev": true }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/globals": { "version": "13.21.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.21.0.tgz", @@ -8435,7 +8590,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.0.tgz", "integrity": "sha512-uKmsITSsF4rUWQHzqaRUuyAir3fZfW3f202Ee34lz/gZCi970CPZwyQXLGNgWJvvZbvFyzeyGq0+4fcG/mBKZg==", - "dev": true, "dependencies": { "@isaacs/cliui": "^8.0.2" }, @@ -8520,6 +8674,68 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-circus/node_modules/@jest/environment": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.6.4.tgz", + "integrity": "sha512-sQ0SULEjA1XUTHmkBRl7A1dyITM9yb1yb3ZNKPX3KlTd6IG7mWUe3e2yfExtC2Zz1Q+mMckOLHmL/qLiuQJrBQ==", + "dev": true, + "dependencies": { + "@jest/fake-timers": "^29.6.4", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus/node_modules/@jest/fake-timers": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.6.4.tgz", + "integrity": "sha512-6UkCwzoBK60edXIIWb0/KWkuj7R7Qq91vVInOe3De6DSpaEiqjKcJw4F7XUet24Wupahj9J6PlR09JqJ5ySDHw==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.6.3", + "jest-mock": "^29.6.3", + "jest-util": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus/node_modules/jest-each": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.6.3.tgz", + "integrity": "sha512-KoXfJ42k8cqbkfshW7sSHcdfnv5agDdHCPA87ZBdmHP+zJstTJc0ttQaJ/x7zK6noAL76hOuTIJ6ZkQRS5dcyg==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.6.3", + "pretty-format": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus/node_modules/jest-mock": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.6.3.tgz", + "integrity": "sha512-Z7Gs/mOyTSR4yPsaZ72a/MtuK6RnC3JYqWONe48oLaoEcYwEDxqvbXz85G4SJrm2Z5Ar9zp6MiHF4AlFlRM4Pg==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/jest-cli": { "version": "29.6.4", "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.6.4.tgz", @@ -8631,6 +8847,26 @@ } } }, + "node_modules/jest-config/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/jest-diff": { "version": "29.6.4", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.6.4.tgz", @@ -8658,22 +8894,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-each": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.6.3.tgz", - "integrity": "sha512-KoXfJ42k8cqbkfshW7sSHcdfnv5agDdHCPA87ZBdmHP+zJstTJc0ttQaJ/x7zK6noAL76hOuTIJ6ZkQRS5dcyg==", - "dev": true, - "dependencies": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "jest-util": "^29.6.3", - "pretty-format": "^29.6.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, "node_modules/jest-environment-node": { "version": "29.6.4", "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.6.4.tgz", @@ -8691,6 +8911,52 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-environment-node/node_modules/@jest/environment": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.6.4.tgz", + "integrity": "sha512-sQ0SULEjA1XUTHmkBRl7A1dyITM9yb1yb3ZNKPX3KlTd6IG7mWUe3e2yfExtC2Zz1Q+mMckOLHmL/qLiuQJrBQ==", + "dev": true, + "dependencies": { + "@jest/fake-timers": "^29.6.4", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node/node_modules/@jest/fake-timers": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.6.4.tgz", + "integrity": "sha512-6UkCwzoBK60edXIIWb0/KWkuj7R7Qq91vVInOe3De6DSpaEiqjKcJw4F7XUet24Wupahj9J6PlR09JqJ5ySDHw==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.6.3", + "jest-mock": "^29.6.3", + "jest-util": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node/node_modules/jest-mock": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.6.3.tgz", + "integrity": "sha512-Z7Gs/mOyTSR4yPsaZ72a/MtuK6RnC3JYqWONe48oLaoEcYwEDxqvbXz85G4SJrm2Z5Ar9zp6MiHF4AlFlRM4Pg==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/jest-get-type": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", @@ -8803,20 +9069,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-mock": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.6.3.tgz", - "integrity": "sha512-Z7Gs/mOyTSR4yPsaZ72a/MtuK6RnC3JYqWONe48oLaoEcYwEDxqvbXz85G4SJrm2Z5Ar9zp6MiHF4AlFlRM4Pg==", - "dev": true, - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-util": "^29.6.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, "node_modules/jest-pnp-resolver": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", @@ -8908,6 +9160,52 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-runner/node_modules/@jest/environment": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.6.4.tgz", + "integrity": "sha512-sQ0SULEjA1XUTHmkBRl7A1dyITM9yb1yb3ZNKPX3KlTd6IG7mWUe3e2yfExtC2Zz1Q+mMckOLHmL/qLiuQJrBQ==", + "dev": true, + "dependencies": { + "@jest/fake-timers": "^29.6.4", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner/node_modules/@jest/fake-timers": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.6.4.tgz", + "integrity": "sha512-6UkCwzoBK60edXIIWb0/KWkuj7R7Qq91vVInOe3De6DSpaEiqjKcJw4F7XUet24Wupahj9J6PlR09JqJ5ySDHw==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.6.3", + "jest-mock": "^29.6.3", + "jest-util": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner/node_modules/jest-mock": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.6.3.tgz", + "integrity": "sha512-Z7Gs/mOyTSR4yPsaZ72a/MtuK6RnC3JYqWONe48oLaoEcYwEDxqvbXz85G4SJrm2Z5Ar9zp6MiHF4AlFlRM4Pg==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/jest-runner/node_modules/jest-worker": { "version": "29.6.4", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.6.4.tgz", @@ -8990,6 +9288,72 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-runtime/node_modules/@jest/environment": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.6.4.tgz", + "integrity": "sha512-sQ0SULEjA1XUTHmkBRl7A1dyITM9yb1yb3ZNKPX3KlTd6IG7mWUe3e2yfExtC2Zz1Q+mMckOLHmL/qLiuQJrBQ==", + "dev": true, + "dependencies": { + "@jest/fake-timers": "^29.6.4", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime/node_modules/@jest/fake-timers": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.6.4.tgz", + "integrity": "sha512-6UkCwzoBK60edXIIWb0/KWkuj7R7Qq91vVInOe3De6DSpaEiqjKcJw4F7XUet24Wupahj9J6PlR09JqJ5ySDHw==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.6.3", + "jest-mock": "^29.6.3", + "jest-util": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-runtime/node_modules/jest-mock": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.6.3.tgz", + "integrity": "sha512-Z7Gs/mOyTSR4yPsaZ72a/MtuK6RnC3JYqWONe48oLaoEcYwEDxqvbXz85G4SJrm2Z5Ar9zp6MiHF4AlFlRM4Pg==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/jest-snapshot": { "version": "29.6.4", "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.6.4.tgz", @@ -9725,15 +10089,24 @@ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" }, + "node_modules/mock-fs": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.2.0.tgz", + "integrity": "sha512-2dF2R6YMSZbpip1V1WHKGLNjr/k48uQClqMVb5H3MOvwc9qhYis3/IWbj02qIg/Y8MDXKFF4c5v0rxx2o6xTZw==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/msgpackr": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.9.2.tgz", - "integrity": "sha512-xtDgI3Xv0AAiZWLRGDchyzBwU6aq0rwJ+W+5Y4CZhEWtkl/hJtFFLc+3JtGTw7nz1yquxs7nL8q/yA2aqpflIQ==", + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.9.7.tgz", + "integrity": "sha512-baUNaLvKQvVhzfWTNO07njwbZK1Lxjtb0P1JL6/EhXdLTHzR57/mZqqJC39TtQKvOmkJA4pcejS4dbk7BDgLLA==", "optionalDependencies": { "msgpackr-extract": "^3.0.2" } @@ -10416,7 +10789,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "engines": { "node": ">=8" } @@ -10431,7 +10803,6 @@ "version": "1.10.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.1.tgz", "integrity": "sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==", - "dev": true, "dependencies": { "lru-cache": "^9.1.1 || ^10.0.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" @@ -10447,7 +10818,6 @@ "version": "9.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-9.1.1.tgz", "integrity": "sha512-65/Jky17UwSb0BuB9V+MyDpsOtXKmYwzhyl+cOa9XUiI4uV2Ouy/2voFP3+al0BjZbJgMBD8FojMpAf+Z+qn4A==", - "dev": true, "engines": { "node": "14 || >=16.14" } @@ -11148,6 +11518,33 @@ "node": ">= 0.10" } }, + "node_modules/redis": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/redis/-/redis-3.1.2.tgz", + "integrity": "sha512-grn5KoZLr/qrRQVwoSkmzdbw6pwF+/rwODtrOr6vuBRiR/f3rjSTGupbF90Zpqm2oenix8Do6RV7pYEkGwlKkw==", + "optional": true, + "peer": true, + "dependencies": { + "denque": "^1.5.0", + "redis-commands": "^1.7.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-redis" + } + }, + "node_modules/redis-commands": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.7.0.tgz", + "integrity": "sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ==", + "optional": true, + "peer": true + }, "node_modules/redis-errors": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", @@ -11167,6 +11564,16 @@ "node": ">=4" } }, + "node_modules/redis/node_modules/denque": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/denque/-/denque-1.5.1.tgz", + "integrity": "sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw==", + "optional": true, + "peer": true, + "engines": { + "node": ">=0.10" + } + }, "node_modules/reflect-metadata": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz", @@ -11337,52 +11744,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/rimraf/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/rimraf/node_modules/glob": { - "version": "10.3.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.3.tgz", - "integrity": "sha512-92vPiMb/iqpmEgsOoIDvTjc50wf9CCCvMzsi6W0JLPeUKE8TWP1a73PgqSrqy7iAZxaSD1YdzU7QZR5LF51MJw==", - "dev": true, - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^2.0.3", - "minimatch": "^9.0.1", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", - "path-scurry": "^1.10.1" - }, - "bin": { - "glob": "dist/cjs/src/bin.js" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/run-applescript": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-5.0.0.tgz", @@ -11738,7 +12099,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -11750,7 +12110,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "engines": { "node": ">=8" } @@ -11772,6 +12131,26 @@ "node": ">=4" } }, + "node_modules/shelljs/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/side-channel": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", @@ -12118,7 +12497,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -12144,7 +12522,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -12494,6 +12871,26 @@ "node": ">=8" } }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/testcontainers": { "version": "10.2.1", "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-10.2.1.tgz", @@ -12534,6 +12931,26 @@ "node": ">= 10" } }, + "node_modules/testcontainers/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/testcontainers/node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -13529,7 +13946,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "dependencies": { "isexe": "^2.0.0" }, @@ -13636,7 +14052,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -14502,7 +14917,6 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, "requires": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", @@ -14515,26 +14929,22 @@ "ansi-regex": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", - "dev": true + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==" }, "ansi-styles": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==" }, "emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" }, "string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, "requires": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", @@ -14545,7 +14955,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, "requires": { "ansi-regex": "^6.0.1" } @@ -14554,7 +14963,6 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, "requires": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", @@ -14696,18 +15104,6 @@ "strip-ansi": "^6.0.0" } }, - "@jest/environment": { - "version": "29.6.4", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.6.4.tgz", - "integrity": "sha512-sQ0SULEjA1XUTHmkBRl7A1dyITM9yb1yb3ZNKPX3KlTd6IG7mWUe3e2yfExtC2Zz1Q+mMckOLHmL/qLiuQJrBQ==", - "dev": true, - "requires": { - "@jest/fake-timers": "^29.6.4", - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-mock": "^29.6.3" - } - }, "@jest/expect": { "version": "29.6.4", "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.6.4.tgz", @@ -14727,20 +15123,6 @@ "jest-get-type": "^29.6.3" } }, - "@jest/fake-timers": { - "version": "29.6.4", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.6.4.tgz", - "integrity": "sha512-6UkCwzoBK60edXIIWb0/KWkuj7R7Qq91vVInOe3De6DSpaEiqjKcJw4F7XUet24Wupahj9J6PlR09JqJ5ySDHw==", - "dev": true, - "requires": { - "@jest/types": "^29.6.3", - "@sinonjs/fake-timers": "^10.0.2", - "@types/node": "*", - "jest-message-util": "^29.6.3", - "jest-mock": "^29.6.3", - "jest-util": "^29.6.3" - } - }, "@jest/globals": { "version": "29.6.4", "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.6.4.tgz", @@ -14751,6 +15133,45 @@ "@jest/expect": "^29.6.4", "@jest/types": "^29.6.3", "jest-mock": "^29.6.3" + }, + "dependencies": { + "@jest/environment": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.6.4.tgz", + "integrity": "sha512-sQ0SULEjA1XUTHmkBRl7A1dyITM9yb1yb3ZNKPX3KlTd6IG7mWUe3e2yfExtC2Zz1Q+mMckOLHmL/qLiuQJrBQ==", + "dev": true, + "requires": { + "@jest/fake-timers": "^29.6.4", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.6.3" + } + }, + "@jest/fake-timers": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.6.4.tgz", + "integrity": "sha512-6UkCwzoBK60edXIIWb0/KWkuj7R7Qq91vVInOe3De6DSpaEiqjKcJw4F7XUet24Wupahj9J6PlR09JqJ5ySDHw==", + "dev": true, + "requires": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.6.3", + "jest-mock": "^29.6.3", + "jest-util": "^29.6.3" + } + }, + "jest-mock": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.6.3.tgz", + "integrity": "sha512-Z7Gs/mOyTSR4yPsaZ72a/MtuK6RnC3JYqWONe48oLaoEcYwEDxqvbXz85G4SJrm2Z5Ar9zp6MiHF4AlFlRM4Pg==", + "dev": true, + "requires": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.6.3" + } + } } }, "@jest/reporters": { @@ -14785,6 +15206,20 @@ "v8-to-istanbul": "^9.0.1" }, "dependencies": { + "glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, "jest-worker": { "version": "29.6.4", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.6.4.tgz", @@ -14967,6 +15402,19 @@ "tar": "^6.1.11" }, "dependencies": { + "glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, "rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -15569,6 +16017,20 @@ "optional": true, "peer": true }, + "glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, "rxjs": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.0.tgz", @@ -15628,7 +16090,6 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, "optional": true }, "@pkgr/utils": { @@ -16226,6 +16687,15 @@ "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==", "dev": true }, + "@types/mock-fs": { + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/@types/mock-fs/-/mock-fs-4.13.1.tgz", + "integrity": "sha512-m6nFAJ3lBSnqbvDZioawRvpLXSaPyn52Srf7OfzjubYbYX8MTUdIgDxQl0wEapm4m/pNYSd9TXocpQ0TvZFlYA==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/multer": { "version": "1.4.7", "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.7.tgz", @@ -16811,6 +17281,19 @@ "normalize-path": "^3.0.0", "readable-stream": "^3.6.0" } + }, + "glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } } } }, @@ -16831,6 +17314,19 @@ "readable-stream": "^2.0.0" }, "dependencies": { + "glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, "readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", @@ -18032,7 +18528,6 @@ "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, "requires": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -18362,8 +18857,7 @@ "eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" }, "easy-table": { "version": "1.1.0", @@ -19086,6 +19580,20 @@ "rimraf": "^3.0.2" }, "dependencies": { + "glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, "rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -19131,7 +19639,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==", - "dev": true, "requires": { "cross-spawn": "^7.0.0", "signal-exit": "^4.0.1" @@ -19140,8 +19647,7 @@ "signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==" } } }, @@ -19362,16 +19868,33 @@ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" }, "glob": { - "version": "7.1.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", - "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "version": "10.3.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.3.tgz", + "integrity": "sha512-92vPiMb/iqpmEgsOoIDvTjc50wf9CCCvMzsi6W0JLPeUKE8TWP1a73PgqSrqy7iAZxaSD1YdzU7QZR5LF51MJw==", "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "foreground-child": "^3.1.0", + "jackspeak": "^2.0.3", + "minimatch": "^9.0.1", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", + "path-scurry": "^1.10.1" + }, + "dependencies": { + "brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "requires": { + "balanced-match": "^1.0.0" + } + }, + "minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "requires": { + "brace-expansion": "^2.0.1" + } + } } }, "glob-parent": { @@ -19967,7 +20490,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.0.tgz", "integrity": "sha512-uKmsITSsF4rUWQHzqaRUuyAir3fZfW3f202Ee34lz/gZCi970CPZwyQXLGNgWJvvZbvFyzeyGq0+4fcG/mBKZg==", - "dev": true, "requires": { "@isaacs/cliui": "^8.0.2", "@pkgjs/parseargs": "^0.11.0" @@ -20022,6 +20544,58 @@ "pure-rand": "^6.0.0", "slash": "^3.0.0", "stack-utils": "^2.0.3" + }, + "dependencies": { + "@jest/environment": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.6.4.tgz", + "integrity": "sha512-sQ0SULEjA1XUTHmkBRl7A1dyITM9yb1yb3ZNKPX3KlTd6IG7mWUe3e2yfExtC2Zz1Q+mMckOLHmL/qLiuQJrBQ==", + "dev": true, + "requires": { + "@jest/fake-timers": "^29.6.4", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.6.3" + } + }, + "@jest/fake-timers": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.6.4.tgz", + "integrity": "sha512-6UkCwzoBK60edXIIWb0/KWkuj7R7Qq91vVInOe3De6DSpaEiqjKcJw4F7XUet24Wupahj9J6PlR09JqJ5ySDHw==", + "dev": true, + "requires": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.6.3", + "jest-mock": "^29.6.3", + "jest-util": "^29.6.3" + } + }, + "jest-each": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.6.3.tgz", + "integrity": "sha512-KoXfJ42k8cqbkfshW7sSHcdfnv5agDdHCPA87ZBdmHP+zJstTJc0ttQaJ/x7zK6noAL76hOuTIJ6ZkQRS5dcyg==", + "dev": true, + "requires": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.6.3", + "pretty-format": "^29.6.3" + } + }, + "jest-mock": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.6.3.tgz", + "integrity": "sha512-Z7Gs/mOyTSR4yPsaZ72a/MtuK6RnC3JYqWONe48oLaoEcYwEDxqvbXz85G4SJrm2Z5Ar9zp6MiHF4AlFlRM4Pg==", + "dev": true, + "requires": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.6.3" + } + } } }, "jest-cli": { @@ -20100,6 +20674,22 @@ "pretty-format": "^29.6.3", "slash": "^3.0.0", "strip-json-comments": "^3.1.1" + }, + "dependencies": { + "glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + } } }, "jest-diff": { @@ -20123,19 +20713,6 @@ "detect-newline": "^3.0.0" } }, - "jest-each": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.6.3.tgz", - "integrity": "sha512-KoXfJ42k8cqbkfshW7sSHcdfnv5agDdHCPA87ZBdmHP+zJstTJc0ttQaJ/x7zK6noAL76hOuTIJ6ZkQRS5dcyg==", - "dev": true, - "requires": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "jest-util": "^29.6.3", - "pretty-format": "^29.6.3" - } - }, "jest-environment-node": { "version": "29.6.4", "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.6.4.tgz", @@ -20148,6 +20725,45 @@ "@types/node": "*", "jest-mock": "^29.6.3", "jest-util": "^29.6.3" + }, + "dependencies": { + "@jest/environment": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.6.4.tgz", + "integrity": "sha512-sQ0SULEjA1XUTHmkBRl7A1dyITM9yb1yb3ZNKPX3KlTd6IG7mWUe3e2yfExtC2Zz1Q+mMckOLHmL/qLiuQJrBQ==", + "dev": true, + "requires": { + "@jest/fake-timers": "^29.6.4", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.6.3" + } + }, + "@jest/fake-timers": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.6.4.tgz", + "integrity": "sha512-6UkCwzoBK60edXIIWb0/KWkuj7R7Qq91vVInOe3De6DSpaEiqjKcJw4F7XUet24Wupahj9J6PlR09JqJ5ySDHw==", + "dev": true, + "requires": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.6.3", + "jest-mock": "^29.6.3", + "jest-util": "^29.6.3" + } + }, + "jest-mock": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.6.3.tgz", + "integrity": "sha512-Z7Gs/mOyTSR4yPsaZ72a/MtuK6RnC3JYqWONe48oLaoEcYwEDxqvbXz85G4SJrm2Z5Ar9zp6MiHF4AlFlRM4Pg==", + "dev": true, + "requires": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.6.3" + } + } } }, "jest-get-type": { @@ -20238,17 +20854,6 @@ "stack-utils": "^2.0.3" } }, - "jest-mock": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.6.3.tgz", - "integrity": "sha512-Z7Gs/mOyTSR4yPsaZ72a/MtuK6RnC3JYqWONe48oLaoEcYwEDxqvbXz85G4SJrm2Z5Ar9zp6MiHF4AlFlRM4Pg==", - "dev": true, - "requires": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-util": "^29.6.3" - } - }, "jest-pnp-resolver": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", @@ -20318,6 +20923,43 @@ "source-map-support": "0.5.13" }, "dependencies": { + "@jest/environment": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.6.4.tgz", + "integrity": "sha512-sQ0SULEjA1XUTHmkBRl7A1dyITM9yb1yb3ZNKPX3KlTd6IG7mWUe3e2yfExtC2Zz1Q+mMckOLHmL/qLiuQJrBQ==", + "dev": true, + "requires": { + "@jest/fake-timers": "^29.6.4", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.6.3" + } + }, + "@jest/fake-timers": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.6.4.tgz", + "integrity": "sha512-6UkCwzoBK60edXIIWb0/KWkuj7R7Qq91vVInOe3De6DSpaEiqjKcJw4F7XUet24Wupahj9J6PlR09JqJ5ySDHw==", + "dev": true, + "requires": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.6.3", + "jest-mock": "^29.6.3", + "jest-util": "^29.6.3" + } + }, + "jest-mock": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.6.3.tgz", + "integrity": "sha512-Z7Gs/mOyTSR4yPsaZ72a/MtuK6RnC3JYqWONe48oLaoEcYwEDxqvbXz85G4SJrm2Z5Ar9zp6MiHF4AlFlRM4Pg==", + "dev": true, + "requires": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.6.3" + } + }, "jest-worker": { "version": "29.6.4", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.6.4.tgz", @@ -20385,6 +21027,59 @@ "jest-util": "^29.6.3", "slash": "^3.0.0", "strip-bom": "^4.0.0" + }, + "dependencies": { + "@jest/environment": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.6.4.tgz", + "integrity": "sha512-sQ0SULEjA1XUTHmkBRl7A1dyITM9yb1yb3ZNKPX3KlTd6IG7mWUe3e2yfExtC2Zz1Q+mMckOLHmL/qLiuQJrBQ==", + "dev": true, + "requires": { + "@jest/fake-timers": "^29.6.4", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.6.3" + } + }, + "@jest/fake-timers": { + "version": "29.6.4", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.6.4.tgz", + "integrity": "sha512-6UkCwzoBK60edXIIWb0/KWkuj7R7Qq91vVInOe3De6DSpaEiqjKcJw4F7XUet24Wupahj9J6PlR09JqJ5ySDHw==", + "dev": true, + "requires": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.6.3", + "jest-mock": "^29.6.3", + "jest-util": "^29.6.3" + } + }, + "glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "jest-mock": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.6.3.tgz", + "integrity": "sha512-Z7Gs/mOyTSR4yPsaZ72a/MtuK6RnC3JYqWONe48oLaoEcYwEDxqvbXz85G4SJrm2Z5Ar9zp6MiHF4AlFlRM4Pg==", + "dev": true, + "requires": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.6.3" + } + } } }, "jest-snapshot": { @@ -20961,15 +21656,21 @@ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" }, + "mock-fs": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.2.0.tgz", + "integrity": "sha512-2dF2R6YMSZbpip1V1WHKGLNjr/k48uQClqMVb5H3MOvwc9qhYis3/IWbj02qIg/Y8MDXKFF4c5v0rxx2o6xTZw==", + "dev": true + }, "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "msgpackr": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.9.2.tgz", - "integrity": "sha512-xtDgI3Xv0AAiZWLRGDchyzBwU6aq0rwJ+W+5Y4CZhEWtkl/hJtFFLc+3JtGTw7nz1yquxs7nL8q/yA2aqpflIQ==", + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.9.7.tgz", + "integrity": "sha512-baUNaLvKQvVhzfWTNO07njwbZK1Lxjtb0P1JL6/EhXdLTHzR57/mZqqJC39TtQKvOmkJA4pcejS4dbk7BDgLLA==", "requires": { "msgpackr-extract": "^3.0.2" } @@ -21475,8 +22176,7 @@ "path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==" }, "path-parse": { "version": "1.0.7", @@ -21488,7 +22188,6 @@ "version": "1.10.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.1.tgz", "integrity": "sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==", - "dev": true, "requires": { "lru-cache": "^9.1.1 || ^10.0.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" @@ -21497,8 +22196,7 @@ "lru-cache": { "version": "9.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-9.1.1.tgz", - "integrity": "sha512-65/Jky17UwSb0BuB9V+MyDpsOtXKmYwzhyl+cOa9XUiI4uV2Ouy/2voFP3+al0BjZbJgMBD8FojMpAf+Z+qn4A==", - "dev": true + "integrity": "sha512-65/Jky17UwSb0BuB9V+MyDpsOtXKmYwzhyl+cOa9XUiI4uV2Ouy/2voFP3+al0BjZbJgMBD8FojMpAf+Z+qn4A==" } } }, @@ -22001,6 +22699,35 @@ "resolve": "^1.1.6" } }, + "redis": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/redis/-/redis-3.1.2.tgz", + "integrity": "sha512-grn5KoZLr/qrRQVwoSkmzdbw6pwF+/rwODtrOr6vuBRiR/f3rjSTGupbF90Zpqm2oenix8Do6RV7pYEkGwlKkw==", + "optional": true, + "peer": true, + "requires": { + "denque": "^1.5.0", + "redis-commands": "^1.7.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0" + }, + "dependencies": { + "denque": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/denque/-/denque-1.5.1.tgz", + "integrity": "sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw==", + "optional": true, + "peer": true + } + } + }, + "redis-commands": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.7.0.tgz", + "integrity": "sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ==", + "optional": true, + "peer": true + }, "redis-errors": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", @@ -22135,39 +22862,6 @@ "dev": true, "requires": { "glob": "^10.2.5" - }, - "dependencies": { - "brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "requires": { - "balanced-match": "^1.0.0" - } - }, - "glob": { - "version": "10.3.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.3.tgz", - "integrity": "sha512-92vPiMb/iqpmEgsOoIDvTjc50wf9CCCvMzsi6W0JLPeUKE8TWP1a73PgqSrqy7iAZxaSD1YdzU7QZR5LF51MJw==", - "dev": true, - "requires": { - "foreground-child": "^3.1.0", - "jackspeak": "^2.0.3", - "minimatch": "^9.0.1", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", - "path-scurry": "^1.10.1" - } - }, - "minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", - "dev": true, - "requires": { - "brace-expansion": "^2.0.1" - } - } } }, "run-applescript": { @@ -22446,7 +23140,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "requires": { "shebang-regex": "^3.0.0" } @@ -22454,8 +23147,7 @@ "shebang-regex": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==" }, "shelljs": { "version": "0.8.5", @@ -22466,6 +23158,22 @@ "glob": "^7.0.0", "interpret": "^1.0.0", "rechoir": "^0.6.2" + }, + "dependencies": { + "glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + } } }, "side-channel": { @@ -22742,7 +23450,6 @@ "version": "npm:string-width@4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "requires": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -22761,7 +23468,6 @@ "version": "npm:strip-ansi@6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "requires": { "ansi-regex": "^5.0.1" } @@ -22991,6 +23697,22 @@ "@istanbuljs/schema": "^0.1.2", "glob": "^7.1.4", "minimatch": "^3.0.4" + }, + "dependencies": { + "glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + } } }, "testcontainers": { @@ -23030,6 +23752,20 @@ "zip-stream": "^4.1.0" } }, + "glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, "rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -23670,7 +24406,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "requires": { "isexe": "^2.0.0" } @@ -23745,7 +24480,6 @@ "version": "npm:wrap-ansi@7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "requires": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", diff --git a/server/package.json b/server/package.json index 08ff88c104..e07dfff9d8 100644 --- a/server/package.json +++ b/server/package.json @@ -61,6 +61,7 @@ "exiftool-vendored": "^23.0.0", "exiftool-vendored.pl": "^12.62.0", "fluent-ffmpeg": "^2.1.2", + "glob": "^10.3.3", "geo-tz": "^7.0.7", "handlebars": "^4.7.8", "i18n-iso-countries": "^7.6.0", @@ -99,6 +100,7 @@ "@types/jest": "29.5.4", "@types/jest-when": "^3.5.2", "@types/lodash": "^4.14.197", + "@types/mock-fs": "^4.13.1", "@types/multer": "^1.4.7", "@types/mv": "^2.1.2", "@types/node": "^20.5.7", @@ -113,6 +115,7 @@ "eslint-plugin-prettier": "^5.0.0", "jest": "^29.6.4", "jest-when": "^3.6.0", + "mock-fs": "^5.2.0", "prettier": "^3.0.2", "prettier-plugin-organize-imports": "^3.2.3", "rimraf": "^5.0.1", diff --git a/server/src/domain/access/access.core.ts b/server/src/domain/access/access.core.ts index b648f8c5e9..894dcc54af 100644 --- a/server/src/domain/access/access.core.ts +++ b/server/src/domain/access/access.core.ts @@ -21,7 +21,13 @@ export enum Permission { ARCHIVE_READ = 'archive.read', + TIMELINE_READ = 'timeline.read', + TIMELINE_DOWNLOAD = 'timeline.download', + + LIBRARY_CREATE = 'library.create', LIBRARY_READ = 'library.read', + LIBRARY_UPDATE = 'library.update', + LIBRARY_DELETE = 'library.delete', LIBRARY_DOWNLOAD = 'library.download', PERSON_READ = 'person.read', @@ -165,12 +171,24 @@ export class AccessCore { case Permission.ARCHIVE_READ: return authUser.id === id; - case Permission.LIBRARY_READ: - return authUser.id === id || (await this.repository.library.hasPartnerAccess(authUser.id, id)); + case Permission.TIMELINE_READ: + return authUser.id === id || (await this.repository.timeline.hasPartnerAccess(authUser.id, id)); - case Permission.LIBRARY_DOWNLOAD: + case Permission.TIMELINE_DOWNLOAD: return authUser.id === id; + case Permission.LIBRARY_READ: + return ( + (await this.repository.library.hasOwnerAccess(authUser.id, id)) || + (await this.repository.library.hasPartnerAccess(authUser.id, id)) + ); + + case Permission.LIBRARY_UPDATE: + return this.repository.library.hasOwnerAccess(authUser.id, id); + + case Permission.LIBRARY_DELETE: + return this.repository.library.hasOwnerAccess(authUser.id, id); + case Permission.PERSON_READ: return this.repository.person.hasOwnerAccess(authUser.id, id); diff --git a/server/src/domain/access/access.repository.ts b/server/src/domain/access/access.repository.ts index 0db8bf606d..02cf94502d 100644 --- a/server/src/domain/access/access.repository.ts +++ b/server/src/domain/access/access.repository.ts @@ -15,6 +15,11 @@ export interface IAccessRepository { }; library: { + hasOwnerAccess(userId: string, libraryId: string): Promise; + hasPartnerAccess(userId: string, partnerId: string): Promise; + }; + + timeline: { hasPartnerAccess(userId: string, partnerId: string): Promise; }; diff --git a/server/src/domain/asset/asset.repository.ts b/server/src/domain/asset/asset.repository.ts index 933e287819..3eb47de026 100644 --- a/server/src/domain/asset/asset.repository.ts +++ b/server/src/domain/asset/asset.repository.ts @@ -45,6 +45,7 @@ export enum WithoutProperty { export enum WithProperty { SIDECAR = 'sidecar', + IS_OFFLINE = 'isOffline', } export enum TimeBucketSize { @@ -69,15 +70,18 @@ export interface TimeBucketItem { export const IAssetRepository = 'IAssetRepository'; export interface IAssetRepository { + create(asset: Partial): Promise; getByDate(ownerId: string, date: Date): Promise; getByIds(ids: string[]): Promise; getByChecksum(userId: string, checksum: Buffer): Promise; getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated; getByUserId(pagination: PaginationOptions, userId: string): Paginated; getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated; - getWith(pagination: PaginationOptions, property: WithProperty): Paginated; + getWith(pagination: PaginationOptions, property: WithProperty, libraryId?: string): Paginated; getFirstAssetForAlbumId(albumId: string): Promise; getLastUpdatedAssetForAlbumId(albumId: string): Promise; + getByLibraryId(libraryIds: string[]): Promise; + getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise; deleteAll(ownerId: string): Promise; getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated; updateAll(ids: string[], options: Partial): Promise; @@ -87,5 +91,7 @@ export interface IAssetRepository { getStatistics(ownerId: string, options: AssetStatsOptions): Promise; getTimeBuckets(options: TimeBucketOptions): Promise; getByTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise; + remove(asset: AssetEntity): Promise; + getById(assetId: string): Promise; upsertExif(exif: Partial): Promise; } diff --git a/server/src/domain/asset/asset.service.spec.ts b/server/src/domain/asset/asset.service.spec.ts index a52fb82234..b0b9d4541e 100644 --- a/server/src/domain/asset/asset.service.spec.ts +++ b/server/src/domain/asset/asset.service.spec.ts @@ -501,6 +501,7 @@ describe(AssetService.name, () => { }); it('should return a list of archives (userId)', async () => { + accessMock.library.hasOwnerAccess.mockResolvedValue(true); assetMock.getByUserId.mockResolvedValue({ items: [assetStub.image, assetStub.video], hasNextPage: false, @@ -514,6 +515,8 @@ describe(AssetService.name, () => { }); it('should split archives by size', async () => { + accessMock.library.hasOwnerAccess.mockResolvedValue(true); + assetMock.getByUserId.mockResolvedValue({ items: [ { ...assetStub.image, id: 'asset-1' }, diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index d85fe175a4..90f8196c9c 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/server/src/domain/asset/asset.service.ts @@ -162,7 +162,7 @@ export class AssetService { if (dto.isArchived !== false) { await this.access.requirePermission(authUser, Permission.ARCHIVE_READ, [dto.userId]); } - await this.access.requirePermission(authUser, Permission.LIBRARY_READ, [dto.userId]); + await this.access.requirePermission(authUser, Permission.TIMELINE_READ, [dto.userId]); } else { dto.userId = authUser.id; } @@ -187,6 +187,10 @@ export class AssetService { throw new BadRequestException('Asset not found'); } + if (asset.isOffline) { + throw new BadRequestException('Asset is offline'); + } + return this.storageRepository.createReadStream(asset.originalPath, mimeTypes.lookup(asset.originalPath)); } @@ -268,7 +272,7 @@ export class AssetService { if (dto.userId) { const userId = dto.userId; - await this.access.requirePermission(authUser, Permission.LIBRARY_DOWNLOAD, userId); + await this.access.requirePermission(authUser, Permission.TIMELINE_DOWNLOAD, userId); return usePagination(PAGINATION_SIZE, (pagination) => this.assetRepository.getByUserId(pagination, userId)); } diff --git a/server/src/domain/asset/response-dto/asset-response.dto.ts b/server/src/domain/asset/response-dto/asset-response.dto.ts index 41e7f20522..6c9adb0532 100644 --- a/server/src/domain/asset/response-dto/asset-response.dto.ts +++ b/server/src/domain/asset/response-dto/asset-response.dto.ts @@ -12,6 +12,7 @@ export class AssetResponseDto { deviceId!: string; ownerId!: string; owner?: UserResponseDto; + libraryId!: string; @ApiProperty({ enumName: 'AssetTypeEnum', enum: AssetType }) type!: AssetType; @@ -25,6 +26,9 @@ export class AssetResponseDto { updatedAt!: Date; isFavorite!: boolean; isArchived!: boolean; + isOffline!: boolean; + isExternal!: boolean; + isReadOnly!: boolean; duration!: string; exifInfo?: ExifResponseDto; smartInfo?: SmartInfoResponseDto; @@ -42,6 +46,7 @@ function _map(entity: AssetEntity, withExif: boolean): AssetResponseDto { ownerId: entity.ownerId, owner: entity.owner ? mapUser(entity.owner) : undefined, deviceId: entity.deviceId, + libraryId: entity.libraryId, type: entity.type, originalPath: entity.originalPath, originalFileName: entity.originalFileName, @@ -59,6 +64,9 @@ function _map(entity: AssetEntity, withExif: boolean): AssetResponseDto { tags: entity.tags?.map(mapTag), people: entity.faces?.map(mapFace).filter((person) => !person.isHidden), checksum: entity.checksum.toString('base64'), + isExternal: entity.isExternal, + isOffline: entity.isOffline, + isReadOnly: entity.isReadOnly, }; } diff --git a/server/src/domain/audit/audit.service.ts b/server/src/domain/audit/audit.service.ts index 47d98e6886..3fa5316bc1 100644 --- a/server/src/domain/audit/audit.service.ts +++ b/server/src/domain/audit/audit.service.ts @@ -25,7 +25,7 @@ export class AuditService { async getDeletes(authUser: AuthUserDto, dto: AuditDeletesDto): Promise { const userId = dto.userId || authUser.id; - await this.access.requirePermission(authUser, Permission.LIBRARY_READ, userId); + await this.access.requirePermission(authUser, Permission.TIMELINE_READ, userId); const audits = await this.repository.getAfter(dto.after, { ownerId: userId, diff --git a/server/src/domain/auth/auth.service.spec.ts b/server/src/domain/auth/auth.service.spec.ts index ab8e59405c..f1e99f322a 100644 --- a/server/src/domain/auth/auth.service.spec.ts +++ b/server/src/domain/auth/auth.service.spec.ts @@ -6,6 +6,7 @@ import { loginResponseStub, newCryptoRepositoryMock, newKeyRepositoryMock, + newLibraryRepositoryMock, newSharedLinkRepositoryMock, newSystemConfigRepositoryMock, newUserRepositoryMock, @@ -20,6 +21,7 @@ import { Issuer, generators } from 'openid-client'; import { Socket } from 'socket.io'; import { IKeyRepository } from '../api-key'; import { ICryptoRepository } from '../crypto/crypto.repository'; +import { ILibraryRepository } from '../library'; import { ISharedLinkRepository } from '../shared-link'; import { ISystemConfigRepository } from '../system-config'; import { IUserRepository } from '../user'; @@ -50,6 +52,7 @@ describe('AuthService', () => { let sut: AuthService; let cryptoMock: jest.Mocked; let userMock: jest.Mocked; + let libraryMock: jest.Mocked; let configMock: jest.Mocked; let userTokenMock: jest.Mocked; let shareMock: jest.Mocked; @@ -81,12 +84,13 @@ describe('AuthService', () => { cryptoMock = newCryptoRepositoryMock(); userMock = newUserRepositoryMock(); + libraryMock = newLibraryRepositoryMock(); configMock = newSystemConfigRepositoryMock(); userTokenMock = newUserTokenRepositoryMock(); shareMock = newSharedLinkRepositoryMock(); keyMock = newKeyRepositoryMock(); - sut = new AuthService(cryptoMock, configMock, userMock, userTokenMock, shareMock, keyMock); + sut = new AuthService(cryptoMock, configMock, userMock, userTokenMock, libraryMock, shareMock, keyMock); }); it('should be defined', () => { diff --git a/server/src/domain/auth/auth.service.ts b/server/src/domain/auth/auth.service.ts index b1cfb1834b..7e0a4d39da 100644 --- a/server/src/domain/auth/auth.service.ts +++ b/server/src/domain/auth/auth.service.ts @@ -13,6 +13,7 @@ import { DateTime } from 'luxon'; import { ClientMetadata, Issuer, UserinfoResponse, custom, generators } from 'openid-client'; import { IKeyRepository } from '../api-key'; import { ICryptoRepository } from '../crypto/crypto.repository'; +import { ILibraryRepository } from '../library'; import { ISharedLinkRepository } from '../shared-link'; import { ISystemConfigRepository } from '../system-config'; import { SystemConfigCore } from '../system-config/system-config.core'; @@ -66,11 +67,12 @@ export class AuthService { @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, @Inject(IUserRepository) userRepository: IUserRepository, @Inject(IUserTokenRepository) private userTokenRepository: IUserTokenRepository, + @Inject(ILibraryRepository) libraryRepository: ILibraryRepository, @Inject(ISharedLinkRepository) private sharedLinkRepository: ISharedLinkRepository, @Inject(IKeyRepository) private keyRepository: IKeyRepository, ) { this.configCore = new SystemConfigCore(configRepository); - this.userCore = new UserCore(userRepository, cryptoRepository); + this.userCore = new UserCore(userRepository, libraryRepository, cryptoRepository); custom.setHttpOptionsDefaults({ timeout: 30000 }); } diff --git a/server/src/domain/domain.constant.ts b/server/src/domain/domain.constant.ts index fb61a0a490..82e9c88d5c 100644 --- a/server/src/domain/domain.constant.ts +++ b/server/src/domain/domain.constant.ts @@ -102,6 +102,7 @@ export const mimeTypes = { video, isAsset: (filename: string) => isType(filename, image) || isType(filename, video), + isImage: (filename: string) => isType(filename, image), isProfile: (filename: string) => isType(filename, profile), isSidecar: (filename: string) => isType(filename, sidecar), isVideo: (filename: string) => isType(filename, video), @@ -115,4 +116,5 @@ export const mimeTypes = { } return AssetType.OTHER; }, + getSupportedFileExtensions: () => Object.keys(image).concat(Object.keys(video)), }; diff --git a/server/src/domain/domain.module.ts b/server/src/domain/domain.module.ts index a2efd8796a..6493cfae66 100644 --- a/server/src/domain/domain.module.ts +++ b/server/src/domain/domain.module.ts @@ -6,6 +6,7 @@ import { AuditService } from './audit'; import { AuthService } from './auth'; import { FacialRecognitionService } from './facial-recognition'; import { JobService } from './job'; +import { LibraryService } from './library'; import { MediaService } from './media'; import { MetadataService } from './metadata'; import { PartnerService } from './partner'; @@ -30,6 +31,7 @@ const providers: Provider[] = [ JobService, MediaService, MetadataService, + LibraryService, PersonService, PartnerService, SearchService, diff --git a/server/src/domain/index.ts b/server/src/domain/index.ts index c66c4eadc5..8fa6332d46 100644 --- a/server/src/domain/index.ts +++ b/server/src/domain/index.ts @@ -12,6 +12,7 @@ export * from './domain.module'; export * from './domain.util'; export * from './facial-recognition'; export * from './job'; +export * from './library'; export * from './media'; export * from './metadata'; export * from './partner'; diff --git a/server/src/domain/job/job.constants.ts b/server/src/domain/job/job.constants.ts index 4342911d9e..0f3bc76470 100644 --- a/server/src/domain/job/job.constants.ts +++ b/server/src/domain/job/job.constants.ts @@ -9,6 +9,7 @@ export enum QueueName { STORAGE_TEMPLATE_MIGRATION = 'storageTemplateMigration', SEARCH = 'search', SIDECAR = 'sidecar', + LIBRARY = 'library', } export enum JobCommand { @@ -53,6 +54,15 @@ export enum JobName { RECOGNIZE_FACES = 'recognize-faces', PERSON_CLEANUP = 'person-cleanup', + // library managment + LIBRARY_SCAN = 'library-refresh', + LIBRARY_SCAN_ASSET = 'library-refresh-asset', + LIBRARY_REMOVE_OFFLINE = 'library-remove-offline', + LIBRARY_MARK_ASSET_OFFLINE = 'library-mark-asset-offline', + LIBRARY_DELETE = 'library-delete', + LIBRARY_QUEUE_SCAN_ALL = 'library-queue-all-refresh', + LIBRARY_QUEUE_CLEANUP = 'library-queue-cleanup', + // cleanup DELETE_FILES = 'delete-files', CLEAN_OLD_AUDIT_LOGS = 'clean-old-audit-logs', @@ -140,4 +150,13 @@ export const JOBS_TO_QUEUE: Record = { [JobName.QUEUE_SIDECAR]: QueueName.SIDECAR, [JobName.SIDECAR_DISCOVERY]: QueueName.SIDECAR, [JobName.SIDECAR_SYNC]: QueueName.SIDECAR, + + // Library managment + [JobName.LIBRARY_SCAN_ASSET]: QueueName.LIBRARY, + [JobName.LIBRARY_MARK_ASSET_OFFLINE]: QueueName.LIBRARY, + [JobName.LIBRARY_SCAN]: QueueName.LIBRARY, + [JobName.LIBRARY_DELETE]: QueueName.LIBRARY, + [JobName.LIBRARY_REMOVE_OFFLINE]: QueueName.LIBRARY, + [JobName.LIBRARY_QUEUE_SCAN_ALL]: QueueName.LIBRARY, + [JobName.LIBRARY_QUEUE_CLEANUP]: QueueName.LIBRARY, }; diff --git a/server/src/domain/job/job.dto.ts b/server/src/domain/job/job.dto.ts index 9e44dfd9d3..c98821d7e9 100644 --- a/server/src/domain/job/job.dto.ts +++ b/server/src/domain/job/job.dto.ts @@ -79,4 +79,7 @@ export class AllJobStatusResponseDto implements Record @ApiProperty({ type: JobStatusDto }) [QueueName.SIDECAR]!: JobStatusDto; + + @ApiProperty({ type: JobStatusDto }) + [QueueName.LIBRARY]!: JobStatusDto; } diff --git a/server/src/domain/job/job.interface.ts b/server/src/domain/job/job.interface.ts index 176b2942af..7996e637f4 100644 --- a/server/src/domain/job/job.interface.ts +++ b/server/src/domain/job/job.interface.ts @@ -22,6 +22,21 @@ export interface IEntityJob extends IBaseJob { source?: 'upload'; } +export interface IOfflineLibraryFileJob extends IEntityJob { + assetPath: string; +} + +export interface ILibraryFileJob extends IEntityJob { + ownerId: string; + assetPath: string; + forceRefresh: boolean; +} + +export interface ILibraryRefreshJob extends IEntityJob { + refreshModifiedFiles: boolean; + refreshAllFiles: boolean; +} + export interface IBulkEntityJob extends IBaseJob { ids: string[]; } diff --git a/server/src/domain/job/job.repository.ts b/server/src/domain/job/job.repository.ts index a452ad4f9b..1ae0144275 100644 --- a/server/src/domain/job/job.repository.ts +++ b/server/src/domain/job/job.repository.ts @@ -1,4 +1,5 @@ import { JobName, QueueName } from './job.constants'; + import { IAssetFaceJob, IBaseJob, @@ -6,6 +7,9 @@ import { IDeleteFilesJob, IEntityJob, IFaceThumbnailJob, + ILibraryFileJob, + ILibraryRefreshJob, + IOfflineLibraryFileJob, } from './job.interface'; export interface JobCounts { @@ -74,6 +78,15 @@ export type JobItem = // Asset Deletion | { name: JobName.PERSON_CLEANUP; data?: IBaseJob } + // Library Managment + | { name: JobName.LIBRARY_SCAN_ASSET; data: ILibraryFileJob } + | { name: JobName.LIBRARY_MARK_ASSET_OFFLINE; data: IOfflineLibraryFileJob } + | { name: JobName.LIBRARY_SCAN; data: ILibraryRefreshJob } + | { name: JobName.LIBRARY_REMOVE_OFFLINE; data: IEntityJob } + | { name: JobName.LIBRARY_DELETE; data: IEntityJob } + | { name: JobName.LIBRARY_QUEUE_SCAN_ALL; data: IBaseJob } + | { name: JobName.LIBRARY_QUEUE_CLEANUP; data: IBaseJob } + // Search | { name: JobName.SEARCH_INDEX_ASSETS; data?: IBaseJob } | { name: JobName.SEARCH_INDEX_ASSET; data: IBulkEntityJob } diff --git a/server/src/domain/job/job.service.spec.ts b/server/src/domain/job/job.service.spec.ts index 2c144baa95..a45958a6ea 100644 --- a/server/src/domain/job/job.service.spec.ts +++ b/server/src/domain/job/job.service.spec.ts @@ -52,6 +52,7 @@ describe(JobService.name, () => { [{ name: JobName.PERSON_CLEANUP }], [{ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } }], [{ name: JobName.CLEAN_OLD_AUDIT_LOGS }], + [{ name: JobName.LIBRARY_QUEUE_SCAN_ALL, data: { force: false } }], ]); }); }); @@ -97,6 +98,7 @@ describe(JobService.name, () => { [QueueName.VIDEO_CONVERSION]: expectedJobStatus, [QueueName.RECOGNIZE_FACES]: expectedJobStatus, [QueueName.SIDECAR]: expectedJobStatus, + [QueueName.LIBRARY]: expectedJobStatus, }); }); }); @@ -225,6 +227,7 @@ describe(JobService.name, () => { [QueueName.RECOGNIZE_FACES]: { concurrency: 10 }, [QueueName.SEARCH]: { concurrency: 10 }, [QueueName.SIDECAR]: { concurrency: 10 }, + [QueueName.LIBRARY]: { concurrency: 10 }, [QueueName.STORAGE_TEMPLATE_MIGRATION]: { concurrency: 10 }, [QueueName.THUMBNAIL_GENERATION]: { concurrency: 10 }, [QueueName.VIDEO_CONVERSION]: { concurrency: 10 }, @@ -237,6 +240,7 @@ describe(JobService.name, () => { expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.OBJECT_TAGGING, 10); expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.RECOGNIZE_FACES, 10); expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.SIDECAR, 10); + expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.LIBRARY, 10); expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.STORAGE_TEMPLATE_MIGRATION, 10); expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.THUMBNAIL_GENERATION, 10); expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.VIDEO_CONVERSION, 10); diff --git a/server/src/domain/job/job.service.ts b/server/src/domain/job/job.service.ts index 7f151689f8..a910f7381c 100644 --- a/server/src/domain/job/job.service.ts +++ b/server/src/domain/job/job.service.ts @@ -98,6 +98,9 @@ export class JobService { await this.configCore.requireFeature(FeatureFlag.FACIAL_RECOGNITION); return this.jobRepository.queue({ name: JobName.QUEUE_RECOGNIZE_FACES, data: { force } }); + case QueueName.LIBRARY: + return this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SCAN_ALL, data: { force } }); + default: throw new BadRequestException(`Invalid job name: ${name}`); } @@ -118,7 +121,7 @@ export class JobService { await this.onDone(item); } } catch (error: Error | any) { - this.logger.error(`Unable to run job handler: ${error}`, error?.stack, data); + this.logger.error(`Unable to run job handler (${queueName}/${name}): ${error}`, error?.stack, data); } }); } @@ -138,6 +141,7 @@ export class JobService { await this.jobRepository.queue({ name: JobName.PERSON_CLEANUP }); await this.jobRepository.queue({ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } }); await this.jobRepository.queue({ name: JobName.CLEAN_OLD_AUDIT_LOGS }); + await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SCAN_ALL, data: { force: false } }); } /** diff --git a/server/src/domain/library/index.ts b/server/src/domain/library/index.ts new file mode 100644 index 0000000000..8adc4449cb --- /dev/null +++ b/server/src/domain/library/index.ts @@ -0,0 +1,3 @@ +export * from './library.dto'; +export * from './library.repository'; +export * from './library.service'; diff --git a/server/src/domain/library/library.dto.ts b/server/src/domain/library/library.dto.ts new file mode 100644 index 0000000000..a3f3078700 --- /dev/null +++ b/server/src/domain/library/library.dto.ts @@ -0,0 +1,124 @@ +import { LibraryEntity, LibraryType } from '@app/infra/entities'; +import { ApiProperty } from '@nestjs/swagger'; +import { IsBoolean, IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { ValidateUUID } from '../domain.util'; + +export class CreateLibraryDto { + @IsEnum(LibraryType) + @ApiProperty({ enumName: 'LibraryType', enum: LibraryType }) + type!: LibraryType; + + @IsString() + @IsOptional() + @IsNotEmpty() + name?: string; + + @IsOptional() + @IsBoolean() + isVisible?: boolean; + + @IsOptional() + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + importPaths?: string[]; + + @IsOptional() + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + exclusionPatterns?: string[]; +} + +export class UpdateLibraryDto { + @IsOptional() + @IsString() + @IsNotEmpty() + name?: string; + + @IsOptional() + @IsBoolean() + isVisible?: boolean; + + @IsOptional() + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + importPaths?: string[]; + + @IsOptional() + @IsNotEmpty({ each: true }) + @IsString({ each: true }) + exclusionPatterns?: string[]; +} + +export class CrawlOptionsDto { + pathsToCrawl!: string[]; + includeHidden? = false; + exclusionPatterns?: string[]; +} + +export class LibrarySearchDto { + @ValidateUUID({ optional: true }) + userId?: string; +} + +export class ScanLibraryDto { + @IsBoolean() + @IsOptional() + refreshModifiedFiles?: boolean; + + @IsBoolean() + @IsOptional() + refreshAllFiles?: boolean = false; +} + +export class LibraryResponseDto { + id!: string; + ownerId!: string; + name!: string; + + @ApiProperty({ enumName: 'LibraryType', enum: LibraryType }) + type!: LibraryType; + + @ApiProperty({ type: 'integer' }) + assetCount!: number; + + importPaths!: string[]; + + exclusionPatterns!: string[]; + + createdAt!: Date; + updatedAt!: Date; + refreshedAt!: Date | null; +} + +export class LibraryStatsResponseDto { + @ApiProperty({ type: 'integer' }) + photos = 0; + + @ApiProperty({ type: 'integer' }) + videos = 0; + + @ApiProperty({ type: 'integer' }) + total = 0; + + @ApiProperty({ type: 'integer', format: 'int64' }) + usage = 0; +} + +export function mapLibrary(entity: LibraryEntity): LibraryResponseDto { + let assetCount = 0; + if (entity.assets) { + assetCount = entity.assets.length; + } + return { + id: entity.id, + ownerId: entity.ownerId, + type: entity.type, + name: entity.name, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + refreshedAt: entity.refreshedAt, + assetCount, + importPaths: entity.importPaths, + exclusionPatterns: entity.exclusionPatterns, + }; +} diff --git a/server/src/domain/library/library.repository.ts b/server/src/domain/library/library.repository.ts new file mode 100644 index 0000000000..46f75170ce --- /dev/null +++ b/server/src/domain/library/library.repository.ts @@ -0,0 +1,22 @@ +import { LibraryEntity, LibraryType } from '@app/infra/entities'; +import { LibraryStatsResponseDto } from './library.dto'; + +export const ILibraryRepository = 'ILibraryRepository'; + +export interface ILibraryRepository { + getCountForUser(ownerId: string): Promise; + getAllByUserId(userId: string, type?: LibraryType): Promise; + getAll(withDeleted?: boolean, type?: LibraryType): Promise; + getAllDeleted(): Promise; + get(id: string, withDeleted?: boolean): Promise; + create(library: Partial): Promise; + delete(id: string): Promise; + softDelete(id: string): Promise; + getDefaultUploadLibrary(ownerId: string): Promise; + getUploadLibraryCount(ownerId: string): Promise; + update(library: Partial): Promise; + getStatistics(id: string): Promise; + getOnlineAssetPaths(id: string): Promise; + getAssetIds(id: string, withDeleted?: boolean): Promise; + existsByName(name: string, withDeleted?: boolean): Promise; +} diff --git a/server/src/domain/library/library.service.spec.ts b/server/src/domain/library/library.service.spec.ts new file mode 100644 index 0000000000..ad1bf046f6 --- /dev/null +++ b/server/src/domain/library/library.service.spec.ts @@ -0,0 +1,1204 @@ +import { AssetType, LibraryType, UserEntity } from '@app/infra/entities'; +import { BadRequestException } from '@nestjs/common'; + +import { + assetStub, + authStub, + IAccessRepositoryMock, + libraryStub, + newAccessRepositoryMock, + newAssetRepositoryMock, + newCryptoRepositoryMock, + newJobRepositoryMock, + newLibraryRepositoryMock, + newStorageRepositoryMock, + newUserRepositoryMock, + userStub, +} from '@test'; +import { Stats } from 'fs'; +import { IJobRepository, ILibraryFileJob, ILibraryRefreshJob, IOfflineLibraryFileJob, JobName } from '../job'; + +import { IAssetRepository, ICryptoRepository, IStorageRepository, IUserRepository } from '..'; +import { ILibraryRepository } from './library.repository'; +import { LibraryService } from './library.service'; + +describe(LibraryService.name, () => { + let sut: LibraryService; + + let accessMock: IAccessRepositoryMock; + let assetMock: jest.Mocked; + let cryptoMock: jest.Mocked; + let userMock: jest.Mocked; + let jobMock: jest.Mocked; + let libraryMock: jest.Mocked; + let storageMock: jest.Mocked; + + beforeEach(() => { + accessMock = newAccessRepositoryMock(); + libraryMock = newLibraryRepositoryMock(); + userMock = newUserRepositoryMock(); + assetMock = newAssetRepositoryMock(); + jobMock = newJobRepositoryMock(); + cryptoMock = newCryptoRepositoryMock(); + storageMock = newStorageRepositoryMock(); + + storageMock.stat.mockResolvedValue({ + size: 100, + mtime: new Date('2023-01-01'), + ctime: new Date('2023-01-01'), + } as Stats); + + accessMock.library.hasOwnerAccess.mockResolvedValue(true); + + sut = new LibraryService(accessMock, assetMock, cryptoMock, jobMock, libraryMock, storageMock, userMock); + }); + + it('should work', () => { + expect(sut).toBeDefined(); + }); + + describe('handleQueueAssetRefresh', () => { + it("should not queue assets outside of user's external path", async () => { + const mockLibraryJob: ILibraryRefreshJob = { + id: libraryStub.externalLibrary1.id, + refreshModifiedFiles: false, + refreshAllFiles: false, + }; + + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + storageMock.crawl.mockResolvedValue(['/data/user2/photo.jpg']); + assetMock.getByLibraryId.mockResolvedValue([]); + libraryMock.getOnlineAssetPaths.mockResolvedValue([]); + userMock.get.mockResolvedValue(userStub.externalPath1); + + await sut.handleQueueAssetRefresh(mockLibraryJob); + + expect(jobMock.queue.mock.calls).toEqual([]); + }); + + it('should queue new assets', async () => { + const mockLibraryJob: ILibraryRefreshJob = { + id: libraryStub.externalLibrary1.id, + refreshModifiedFiles: false, + refreshAllFiles: false, + }; + + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + storageMock.crawl.mockResolvedValue(['/data/user1/photo.jpg']); + assetMock.getByLibraryId.mockResolvedValue([]); + libraryMock.getOnlineAssetPaths.mockResolvedValue([]); + userMock.get.mockResolvedValue(userStub.externalPath1); + + await sut.handleQueueAssetRefresh(mockLibraryJob); + + expect(jobMock.queue.mock.calls).toEqual([ + [ + { + name: JobName.LIBRARY_SCAN_ASSET, + data: { + id: libraryStub.externalLibrary1.id, + ownerId: libraryStub.externalLibrary1.owner.id, + assetPath: '/data/user1/photo.jpg', + forceRefresh: false, + }, + }, + ], + ]); + }); + + it("should mark assets outside of the user's external path as offline", async () => { + const mockLibraryJob: ILibraryRefreshJob = { + id: libraryStub.externalLibrary1.id, + refreshModifiedFiles: false, + refreshAllFiles: false, + }; + + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + storageMock.crawl.mockResolvedValue(['/data/user1/photo.jpg']); + assetMock.getByLibraryId.mockResolvedValue([assetStub.external]); + libraryMock.getOnlineAssetPaths.mockResolvedValue([]); + userMock.get.mockResolvedValue(userStub.externalPath2); + + await sut.handleQueueAssetRefresh(mockLibraryJob); + + expect(jobMock.queue.mock.calls).toEqual([ + [ + { + name: JobName.LIBRARY_MARK_ASSET_OFFLINE, + data: { + id: libraryStub.externalLibrary1.id, + assetPath: '/data/user1/photo.jpg', + }, + }, + ], + ]); + }); + + it('should not scan libraries owned by user without external path', async () => { + const mockLibraryJob: ILibraryRefreshJob = { + id: libraryStub.externalLibrary1.id, + refreshModifiedFiles: false, + refreshAllFiles: false, + }; + + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + + userMock.get.mockResolvedValue(userStub.user1); + + expect(sut.handleQueueAssetRefresh(mockLibraryJob)).resolves.toBe(false); + }); + + it('should not scan upload libraries', async () => { + const mockLibraryJob: ILibraryRefreshJob = { + id: libraryStub.externalLibrary1.id, + refreshModifiedFiles: false, + refreshAllFiles: false, + }; + + libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1); + + expect(sut.handleQueueAssetRefresh(mockLibraryJob)).resolves.toBe(false); + }); + }); + + describe('handleAssetRefresh', () => { + let mockUser: UserEntity; + + beforeEach(() => { + mockUser = userStub.externalPath1; + userMock.get.mockResolvedValue(mockUser); + }); + + it('should reject an unknown file extension', async () => { + const mockLibraryJob: ILibraryFileJob = { + id: libraryStub.externalLibrary1.id, + ownerId: mockUser.id, + assetPath: '/data/user1/file.xyz', + forceRefresh: false, + }; + + assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); + + await expect(sut.handleAssetRefresh(mockLibraryJob)).rejects.toBeInstanceOf(BadRequestException); + }); + + it('should reject an unknown file type', async () => { + const mockLibraryJob: ILibraryFileJob = { + id: libraryStub.externalLibrary1.id, + ownerId: mockUser.id, + assetPath: '/data/user1/file.xyz', + forceRefresh: false, + }; + + await expect(sut.handleAssetRefresh(mockLibraryJob)).rejects.toBeInstanceOf(BadRequestException); + }); + + it('should add a new image', async () => { + const mockLibraryJob: ILibraryFileJob = { + id: libraryStub.externalLibrary1.id, + ownerId: mockUser.id, + assetPath: '/data/user1/photo.jpg', + forceRefresh: false, + }; + + assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); + assetMock.create.mockResolvedValue(assetStub.image); + + await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(true); + + expect(assetMock.create.mock.calls).toEqual([ + [ + { + ownerId: mockUser.id, + libraryId: libraryStub.externalLibrary1.id, + checksum: expect.any(Buffer), + originalPath: '/data/user1/photo.jpg', + deviceAssetId: expect.any(String), + deviceId: 'Library Import', + fileCreatedAt: expect.any(Date), + fileModifiedAt: expect.any(Date), + type: AssetType.IMAGE, + originalFileName: 'photo', + sidecarPath: null, + isReadOnly: true, + isExternal: true, + }, + ], + ]); + + expect(jobMock.queue.mock.calls).toEqual([ + [ + { + name: JobName.METADATA_EXTRACTION, + data: { + id: assetStub.image.id, + source: 'upload', + }, + }, + ], + ]); + }); + + it('should add a new image with sidecar', async () => { + const mockLibraryJob: ILibraryFileJob = { + id: libraryStub.externalLibrary1.id, + ownerId: mockUser.id, + assetPath: '/data/user1/photo.jpg', + forceRefresh: false, + }; + + assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); + assetMock.create.mockResolvedValue(assetStub.image); + storageMock.checkFileExists.mockResolvedValue(true); + + await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(true); + + expect(assetMock.create.mock.calls).toEqual([ + [ + { + ownerId: mockUser.id, + libraryId: libraryStub.externalLibrary1.id, + checksum: expect.any(Buffer), + originalPath: '/data/user1/photo.jpg', + deviceAssetId: expect.any(String), + deviceId: 'Library Import', + fileCreatedAt: expect.any(Date), + fileModifiedAt: expect.any(Date), + type: AssetType.IMAGE, + originalFileName: 'photo', + sidecarPath: '/data/user1/photo.jpg.xmp', + isReadOnly: true, + isExternal: true, + }, + ], + ]); + + expect(jobMock.queue.mock.calls).toEqual([ + [ + { + name: JobName.METADATA_EXTRACTION, + data: { + id: assetStub.image.id, + source: 'upload', + }, + }, + ], + ]); + }); + + it('should add a new video', async () => { + const mockLibraryJob: ILibraryFileJob = { + id: libraryStub.externalLibrary1.id, + ownerId: mockUser.id, + assetPath: '/data/user1/video.mp4', + forceRefresh: false, + }; + + assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); + assetMock.create.mockResolvedValue(assetStub.video); + + await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(true); + + expect(assetMock.create.mock.calls).toEqual([ + [ + { + ownerId: mockUser.id, + libraryId: libraryStub.externalLibrary1.id, + checksum: expect.any(Buffer), + originalPath: '/data/user1/video.mp4', + deviceAssetId: expect.any(String), + deviceId: 'Library Import', + fileCreatedAt: expect.any(Date), + fileModifiedAt: expect.any(Date), + type: AssetType.VIDEO, + originalFileName: 'video', + sidecarPath: null, + isReadOnly: true, + isExternal: true, + }, + ], + ]); + + expect(jobMock.queue.mock.calls).toEqual([ + [ + { + name: JobName.METADATA_EXTRACTION, + data: { + id: assetStub.image.id, + source: 'upload', + }, + }, + ], + [ + { + name: JobName.VIDEO_CONVERSION, + data: { + id: assetStub.video.id, + }, + }, + ], + ]); + }); + + it('should not add an image to a soft deleted library', async () => { + const mockLibraryJob: ILibraryFileJob = { + id: libraryStub.externalLibrary1.id, + ownerId: mockUser.id, + assetPath: '/data/user1/photo.jpg', + forceRefresh: false, + }; + + assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); + assetMock.create.mockResolvedValue(assetStub.image); + libraryMock.get.mockResolvedValue({ ...libraryStub.externalLibrary1, deletedAt: new Date() }); + + await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(false); + + expect(assetMock.create.mock.calls).toEqual([]); + }); + + it('should not import an asset when mtime matches db asset', async () => { + const mockLibraryJob: ILibraryFileJob = { + id: libraryStub.externalLibrary1.id, + ownerId: mockUser.id, + assetPath: '/data/user1/photo.jpg', + forceRefresh: false, + }; + + storageMock.stat.mockResolvedValue({ + size: 100, + mtime: assetStub.image.fileModifiedAt, + ctime: new Date('2023-01-01'), + } as Stats); + + assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); + + await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(true); + + expect(jobMock.queue).not.toHaveBeenCalled(); + }); + + it('should import an asset when mtime differs from db asset', async () => { + const mockLibraryJob: ILibraryFileJob = { + id: libraryStub.externalLibrary1.id, + ownerId: mockUser.id, + assetPath: '/data/user1/photo.jpg', + forceRefresh: false, + }; + + assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); + assetMock.create.mockResolvedValue(assetStub.image); + + await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(true); + + expect(jobMock.queue).toHaveBeenCalledWith({ + name: JobName.METADATA_EXTRACTION, + data: { + id: assetStub.image.id, + source: 'upload', + }, + }); + + expect(jobMock.queue).not.toHaveBeenCalledWith({ + name: JobName.VIDEO_CONVERSION, + data: { + id: assetStub.image.id, + }, + }); + }); + + it('should skip an asset if the user cannot be found', async () => { + userMock.get.mockResolvedValue(null); + + const mockLibraryJob: ILibraryFileJob = { + id: libraryStub.externalLibrary1.id, + ownerId: mockUser.id, + assetPath: '/data/user1/photo.jpg', + forceRefresh: false, + }; + + expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(false); + }); + + it('should skip an asset if external path is not set', async () => { + mockUser = userStub.admin; + userMock.get.mockResolvedValue(mockUser); + + const mockLibraryJob: ILibraryFileJob = { + id: libraryStub.externalLibrary1.id, + ownerId: mockUser.id, + assetPath: '/data/user1/photo.jpg', + forceRefresh: false, + }; + + expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(false); + }); + + it("should skip an asset if it isn't in the external path", async () => { + mockUser = userStub.externalPath1; + userMock.get.mockResolvedValue(mockUser); + + const mockLibraryJob: ILibraryFileJob = { + id: libraryStub.externalLibrary1.id, + ownerId: mockUser.id, + assetPath: '/etc/rootpassword.jpg', + forceRefresh: false, + }; + + expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(false); + }); + + it('should skip an asset if directory traversal is attempted', async () => { + mockUser = userStub.externalPath1; + userMock.get.mockResolvedValue(mockUser); + + const mockLibraryJob: ILibraryFileJob = { + id: libraryStub.externalLibrary1.id, + ownerId: mockUser.id, + assetPath: '/data/user1/../../etc/rootpassword.jpg', + forceRefresh: false, + }; + + expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(false); + }); + + it('should set a missing asset to offline', async () => { + storageMock.stat.mockRejectedValue(new Error()); + + const mockLibraryJob: ILibraryFileJob = { + id: assetStub.image.id, + ownerId: mockUser.id, + assetPath: '/data/user1/photo.jpg', + forceRefresh: false, + }; + + assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); + assetMock.create.mockResolvedValue(assetStub.image); + + await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(true); + + expect(assetMock.save).toHaveBeenCalledWith({ id: assetStub.image.id, isOffline: true }); + expect(jobMock.queue).not.toHaveBeenCalled(); + }); + + it('should online a previously-offline asset', async () => { + const mockLibraryJob: ILibraryFileJob = { + id: assetStub.offline.id, + ownerId: mockUser.id, + assetPath: '/data/user1/photo.jpg', + forceRefresh: false, + }; + + assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.offline); + assetMock.create.mockResolvedValue(assetStub.offline); + + await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(true); + + expect(assetMock.save).toHaveBeenCalledWith({ id: assetStub.offline.id, isOffline: false }); + + expect(jobMock.queue).toHaveBeenCalledWith({ + name: JobName.METADATA_EXTRACTION, + data: { + id: assetStub.offline.id, + source: 'upload', + }, + }); + + expect(jobMock.queue).not.toHaveBeenCalledWith({ + name: JobName.VIDEO_CONVERSION, + data: { + id: assetStub.offline.id, + }, + }); + }); + + it('should do nothing when mtime matches existing asset', async () => { + const mockLibraryJob: ILibraryFileJob = { + id: assetStub.image.id, + ownerId: assetStub.image.ownerId, + assetPath: '/data/user1/photo.jpg', + forceRefresh: false, + }; + + assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); + assetMock.create.mockResolvedValue(assetStub.image); + + expect(assetMock.save).not.toHaveBeenCalled(); + + await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(true); + }); + + it('should refresh an existing asset if forced', async () => { + const mockLibraryJob: ILibraryFileJob = { + id: assetStub.image.id, + ownerId: assetStub.image.ownerId, + assetPath: '/data/user1/photo.jpg', + forceRefresh: true, + }; + + assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); + assetMock.create.mockResolvedValue(assetStub.image); + + await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(true); + + expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.image.id], { + fileCreatedAt: new Date('2023-01-01'), + fileModifiedAt: new Date('2023-01-01'), + }); + }); + + it('should refresh an existing asset with modified mtime', async () => { + const filemtime = new Date(); + filemtime.setSeconds(assetStub.image.fileModifiedAt.getSeconds() + 10); + + const mockLibraryJob: ILibraryFileJob = { + id: libraryStub.externalLibrary1.id, + ownerId: userStub.admin.id, + assetPath: '/data/user1/photo.jpg', + forceRefresh: false, + }; + + storageMock.stat.mockResolvedValue({ + size: 100, + mtime: filemtime, + ctime: new Date('2023-01-01'), + } as Stats); + + assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); + assetMock.create.mockResolvedValue(assetStub.image); + + await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(true); + + expect(assetMock.create).toHaveBeenCalled(); + const createdAsset = assetMock.create.mock.calls[0][0]; + + expect(createdAsset.fileModifiedAt).toEqual(filemtime); + }); + + it('should error when asset does not exist', async () => { + storageMock.stat.mockRejectedValue(new Error("ENOENT, no such file or directory '/data/user1/photo.jpg'")); + + const mockLibraryJob: ILibraryFileJob = { + id: libraryStub.externalLibrary1.id, + ownerId: userStub.admin.id, + assetPath: '/data/user1/photo.jpg', + forceRefresh: false, + }; + + assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); + assetMock.create.mockResolvedValue(assetStub.image); + + await expect(sut.handleAssetRefresh(mockLibraryJob)).rejects.toBeInstanceOf(BadRequestException); + }); + }); + + describe('handleOfflineAsset', () => { + it('should mark an asset as offline', async () => { + const offlineJob: IOfflineLibraryFileJob = { + id: libraryStub.externalLibrary1.id, + assetPath: '/data/user1/photo.jpg', + }; + + assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); + + await expect(sut.handleOfflineAsset(offlineJob)).resolves.toBe(true); + + expect(assetMock.save).toHaveBeenCalledWith({ + id: assetStub.image.id, + isOffline: true, + }); + }); + }); + + describe('delete', () => { + it('should delete a library', async () => { + assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); + libraryMock.getUploadLibraryCount.mockResolvedValue(2); + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + + await sut.delete(authStub.admin, libraryStub.externalLibrary1.id); + + expect(jobMock.queue).toHaveBeenCalledWith({ + name: JobName.LIBRARY_DELETE, + data: { id: libraryStub.externalLibrary1.id }, + }); + + expect(libraryMock.softDelete).toHaveBeenCalledWith(libraryStub.externalLibrary1.id); + }); + + it('should throw error if the last upload library is deleted', async () => { + assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); + libraryMock.getUploadLibraryCount.mockResolvedValue(1); + libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1); + + await expect(sut.delete(authStub.admin, libraryStub.uploadLibrary1.id)).rejects.toBeInstanceOf( + BadRequestException, + ); + + expect(jobMock.queue).not.toHaveBeenCalled(); + expect(libraryMock.softDelete).not.toHaveBeenCalled(); + }); + + it('should allow an external library to be deleted', async () => { + assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); + libraryMock.getUploadLibraryCount.mockResolvedValue(1); + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + + await sut.delete(authStub.admin, libraryStub.externalLibrary1.id); + + expect(jobMock.queue).toHaveBeenCalledWith({ + name: JobName.LIBRARY_DELETE, + data: { id: libraryStub.externalLibrary1.id }, + }); + + expect(libraryMock.softDelete).toHaveBeenCalledWith(libraryStub.externalLibrary1.id); + }); + }); + + describe('getCount', () => { + it('should call the repository', async () => { + libraryMock.getCountForUser.mockResolvedValue(17); + + await expect(sut.getCount(authStub.admin)).resolves.toBe(17); + + expect(libraryMock.getCountForUser).toHaveBeenCalledWith(authStub.admin.id); + }); + }); + + describe('get', () => { + it('can return a library', async () => { + libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1); + await expect(sut.get(authStub.admin, libraryStub.uploadLibrary1.id)).resolves.toEqual( + expect.objectContaining({ + id: libraryStub.uploadLibrary1.id, + name: libraryStub.uploadLibrary1.name, + ownerId: libraryStub.uploadLibrary1.ownerId, + }), + ); + + expect(libraryMock.get).toHaveBeenCalledWith(libraryStub.uploadLibrary1.id); + }); + + it('should throw an error when a library is not found', async () => { + libraryMock.get.mockResolvedValue(null); + await expect(sut.get(authStub.admin, libraryStub.uploadLibrary1.id)).rejects.toBeInstanceOf(BadRequestException); + expect(libraryMock.get).toHaveBeenCalledWith(libraryStub.uploadLibrary1.id); + }); + }); + + describe('getAllForUser', () => { + it('can return all libraries for user', async () => { + libraryMock.getAllByUserId.mockResolvedValue([libraryStub.uploadLibrary1, libraryStub.externalLibrary1]); + await expect(sut.getAllForUser(authStub.admin)).resolves.toEqual([ + expect.objectContaining({ + id: libraryStub.uploadLibrary1.id, + name: libraryStub.uploadLibrary1.name, + ownerId: libraryStub.uploadLibrary1.ownerId, + }), + expect.objectContaining({ + id: libraryStub.externalLibrary1.id, + name: libraryStub.externalLibrary1.name, + ownerId: libraryStub.externalLibrary1.ownerId, + }), + ]); + + expect(libraryMock.getAllByUserId).toHaveBeenCalledWith(authStub.admin.id); + }); + }); + + describe('getStatistics', () => { + it('can return library statistics', async () => { + libraryMock.getStatistics.mockResolvedValue({ photos: 10, videos: 0, total: 10, usage: 1337 }); + await expect(sut.getStatistics(authStub.admin, libraryStub.uploadLibrary1.id)).resolves.toEqual({ + photos: 10, + videos: 0, + total: 10, + usage: 1337, + }); + + expect(libraryMock.getStatistics).toHaveBeenCalledWith(libraryStub.uploadLibrary1.id); + }); + }); + + describe('create', () => { + describe('external library', () => { + it('can create with default settings', async () => { + libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1); + await expect( + sut.create(authStub.admin, { + type: LibraryType.EXTERNAL, + }), + ).resolves.toEqual( + expect.objectContaining({ + id: libraryStub.externalLibrary1.id, + type: LibraryType.EXTERNAL, + name: libraryStub.externalLibrary1.name, + ownerId: libraryStub.externalLibrary1.ownerId, + assetCount: 0, + importPaths: [], + exclusionPatterns: [], + createdAt: libraryStub.externalLibrary1.createdAt, + updatedAt: libraryStub.externalLibrary1.updatedAt, + refreshedAt: null, + }), + ); + + expect(libraryMock.create).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'New External Library', + type: LibraryType.EXTERNAL, + importPaths: [], + exclusionPatterns: [], + isVisible: true, + }), + ); + }); + + it('can create with name', async () => { + libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1); + await expect( + sut.create(authStub.admin, { + type: LibraryType.EXTERNAL, + name: 'My Awesome Library', + }), + ).resolves.toEqual( + expect.objectContaining({ + id: libraryStub.externalLibrary1.id, + type: LibraryType.EXTERNAL, + name: libraryStub.externalLibrary1.name, + ownerId: libraryStub.externalLibrary1.ownerId, + assetCount: 0, + importPaths: [], + exclusionPatterns: [], + createdAt: libraryStub.externalLibrary1.createdAt, + updatedAt: libraryStub.externalLibrary1.updatedAt, + refreshedAt: null, + }), + ); + + expect(libraryMock.create).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'My Awesome Library', + type: LibraryType.EXTERNAL, + importPaths: [], + exclusionPatterns: [], + isVisible: true, + }), + ); + }); + + it('can create invisible', async () => { + libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1); + await expect( + sut.create(authStub.admin, { + type: LibraryType.EXTERNAL, + isVisible: false, + }), + ).resolves.toEqual( + expect.objectContaining({ + id: libraryStub.externalLibrary1.id, + type: LibraryType.EXTERNAL, + name: libraryStub.externalLibrary1.name, + ownerId: libraryStub.externalLibrary1.ownerId, + assetCount: 0, + importPaths: [], + exclusionPatterns: [], + createdAt: libraryStub.externalLibrary1.createdAt, + updatedAt: libraryStub.externalLibrary1.updatedAt, + refreshedAt: null, + }), + ); + + expect(libraryMock.create).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'New External Library', + type: LibraryType.EXTERNAL, + importPaths: [], + exclusionPatterns: [], + isVisible: false, + }), + ); + }); + + it('can create with import paths', async () => { + libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1); + await expect( + sut.create(authStub.admin, { + type: LibraryType.EXTERNAL, + importPaths: ['/data/images', '/data/videos'], + }), + ).resolves.toEqual( + expect.objectContaining({ + id: libraryStub.externalLibrary1.id, + type: LibraryType.EXTERNAL, + name: libraryStub.externalLibrary1.name, + ownerId: libraryStub.externalLibrary1.ownerId, + assetCount: 0, + importPaths: [], + exclusionPatterns: [], + createdAt: libraryStub.externalLibrary1.createdAt, + updatedAt: libraryStub.externalLibrary1.updatedAt, + refreshedAt: null, + }), + ); + + expect(libraryMock.create).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'New External Library', + type: LibraryType.EXTERNAL, + importPaths: ['/data/images', '/data/videos'], + exclusionPatterns: [], + isVisible: true, + }), + ); + }); + + it('can create with exclusion patterns', async () => { + libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1); + await expect( + sut.create(authStub.admin, { + type: LibraryType.EXTERNAL, + exclusionPatterns: ['*.tmp', '*.bak'], + }), + ).resolves.toEqual( + expect.objectContaining({ + id: libraryStub.externalLibrary1.id, + type: LibraryType.EXTERNAL, + name: libraryStub.externalLibrary1.name, + ownerId: libraryStub.externalLibrary1.ownerId, + assetCount: 0, + importPaths: [], + exclusionPatterns: [], + createdAt: libraryStub.externalLibrary1.createdAt, + updatedAt: libraryStub.externalLibrary1.updatedAt, + refreshedAt: null, + }), + ); + + expect(libraryMock.create).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'New External Library', + type: LibraryType.EXTERNAL, + importPaths: [], + exclusionPatterns: ['*.tmp', '*.bak'], + isVisible: true, + }), + ); + }); + }); + + describe('upload library', () => { + it('can create with default settings', async () => { + libraryMock.create.mockResolvedValue(libraryStub.uploadLibrary1); + await expect( + sut.create(authStub.admin, { + type: LibraryType.UPLOAD, + }), + ).resolves.toEqual( + expect.objectContaining({ + id: libraryStub.uploadLibrary1.id, + type: LibraryType.UPLOAD, + name: libraryStub.uploadLibrary1.name, + ownerId: libraryStub.uploadLibrary1.ownerId, + assetCount: 0, + importPaths: [], + exclusionPatterns: [], + createdAt: libraryStub.uploadLibrary1.createdAt, + updatedAt: libraryStub.uploadLibrary1.updatedAt, + refreshedAt: null, + }), + ); + + expect(libraryMock.create).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'New Upload Library', + type: LibraryType.UPLOAD, + importPaths: [], + exclusionPatterns: [], + isVisible: true, + }), + ); + }); + + it('can create with name', async () => { + libraryMock.create.mockResolvedValue(libraryStub.uploadLibrary1); + await expect( + sut.create(authStub.admin, { + type: LibraryType.UPLOAD, + name: 'My Awesome Library', + }), + ).resolves.toEqual( + expect.objectContaining({ + id: libraryStub.uploadLibrary1.id, + type: LibraryType.UPLOAD, + name: libraryStub.uploadLibrary1.name, + ownerId: libraryStub.uploadLibrary1.ownerId, + assetCount: 0, + importPaths: [], + exclusionPatterns: [], + createdAt: libraryStub.uploadLibrary1.createdAt, + updatedAt: libraryStub.uploadLibrary1.updatedAt, + refreshedAt: null, + }), + ); + + expect(libraryMock.create).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'My Awesome Library', + type: LibraryType.UPLOAD, + importPaths: [], + exclusionPatterns: [], + isVisible: true, + }), + ); + }); + + it('can not create with import paths', async () => { + await expect( + sut.create(authStub.admin, { + type: LibraryType.UPLOAD, + importPaths: ['/data/images', '/data/videos'], + }), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(libraryMock.create).not.toHaveBeenCalled(); + }); + + it('can not create with exclusion patterns', async () => { + await expect( + sut.create(authStub.admin, { + type: LibraryType.UPLOAD, + exclusionPatterns: ['*.tmp', '*.bak'], + }), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(libraryMock.create).not.toHaveBeenCalled(); + }); + }); + }); + + describe('handleQueueCleanup', () => { + it('can queue cleanup jobs', async () => { + libraryMock.getAllDeleted.mockResolvedValue([libraryStub.uploadLibrary1, libraryStub.externalLibrary1]); + await expect(sut.handleQueueCleanup()).resolves.toBe(true); + + expect(jobMock.queue.mock.calls).toEqual([ + [{ name: JobName.LIBRARY_DELETE, data: { id: libraryStub.uploadLibrary1.id } }], + [{ name: JobName.LIBRARY_DELETE, data: { id: libraryStub.externalLibrary1.id } }], + ]); + }); + }); + + describe('update', () => { + it('can update library ', async () => { + libraryMock.update.mockResolvedValue(libraryStub.uploadLibrary1); + await expect(sut.update(authStub.admin, authStub.admin.id, {})).resolves.toBeTruthy(); + expect(libraryMock.update).toHaveBeenCalledWith( + expect.objectContaining({ + id: authStub.admin.id, + }), + ); + }); + }); + + describe('handleDeleteLibrary', () => { + it('can not delete a nonexistent library', async () => { + libraryMock.get.mockImplementation(async () => { + return null; + }); + libraryMock.getAssetIds.mockResolvedValue([]); + libraryMock.delete.mockImplementation(async () => {}); + + await expect(sut.handleDeleteLibrary({ id: libraryStub.uploadLibrary1.id })).resolves.toBe(false); + }); + + it('can delete an empty library', async () => { + libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1); + libraryMock.getAssetIds.mockResolvedValue([]); + libraryMock.delete.mockImplementation(async () => {}); + + await expect(sut.handleDeleteLibrary({ id: libraryStub.uploadLibrary1.id })).resolves.toBe(true); + }); + + it('can delete a library with assets', async () => { + libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1); + libraryMock.getAssetIds.mockResolvedValue([assetStub.image1.id]); + libraryMock.delete.mockImplementation(async () => {}); + + assetMock.getById.mockResolvedValue(assetStub.image1); + + await expect(sut.handleDeleteLibrary({ id: libraryStub.uploadLibrary1.id })).resolves.toBe(true); + }); + }); + + describe('queueScan', () => { + it('can queue a library scan of external library', async () => { + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + + await sut.queueScan(authStub.admin, libraryStub.externalLibrary1.id, {}); + + expect(jobMock.queue.mock.calls).toEqual([ + [ + { + name: JobName.LIBRARY_SCAN, + data: { + id: libraryStub.externalLibrary1.id, + refreshModifiedFiles: false, + refreshAllFiles: false, + }, + }, + ], + ]); + }); + + it('can not queue a library scan of upload library', async () => { + libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1); + + await expect(sut.queueScan(authStub.admin, libraryStub.uploadLibrary1.id, {})).rejects.toBeInstanceOf( + BadRequestException, + ); + + expect(jobMock.queue).not.toBeCalled(); + }); + + it('can queue a library scan of all modified assets', async () => { + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + + await sut.queueScan(authStub.admin, libraryStub.externalLibrary1.id, { refreshModifiedFiles: true }); + + expect(jobMock.queue.mock.calls).toEqual([ + [ + { + name: JobName.LIBRARY_SCAN, + data: { + id: libraryStub.externalLibrary1.id, + refreshModifiedFiles: true, + refreshAllFiles: false, + }, + }, + ], + ]); + }); + + it('can queue a forced library scan', async () => { + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + + await sut.queueScan(authStub.admin, libraryStub.externalLibrary1.id, { refreshAllFiles: true }); + + expect(jobMock.queue.mock.calls).toEqual([ + [ + { + name: JobName.LIBRARY_SCAN, + data: { + id: libraryStub.externalLibrary1.id, + refreshModifiedFiles: false, + refreshAllFiles: true, + }, + }, + ], + ]); + }); + }); + + describe('queueEmptyTrash', () => { + it('can queue the trash job', async () => { + await sut.queueRemoveOffline(authStub.admin, libraryStub.externalLibrary1.id); + + expect(jobMock.queue.mock.calls).toEqual([ + [ + { + name: JobName.LIBRARY_REMOVE_OFFLINE, + data: { + id: libraryStub.externalLibrary1.id, + }, + }, + ], + ]); + }); + }); + + describe('handleQueueAllScan', () => { + it('can queue the refresh job', async () => { + libraryMock.getAll.mockResolvedValue([libraryStub.externalLibrary1]); + + await expect(sut.handleQueueAllScan({})).resolves.toBe(true); + + expect(jobMock.queue.mock.calls).toEqual([ + [ + { + name: JobName.LIBRARY_QUEUE_CLEANUP, + data: {}, + }, + ], + [ + { + name: JobName.LIBRARY_SCAN, + data: { + id: libraryStub.externalLibrary1.id, + refreshModifiedFiles: true, + refreshAllFiles: false, + }, + }, + ], + ]); + }); + + it('can queue the force refresh job', async () => { + libraryMock.getAll.mockResolvedValue([libraryStub.externalLibrary1]); + + await expect(sut.handleQueueAllScan({ force: true })).resolves.toBe(true); + + expect(jobMock.queue.mock.calls).toEqual([ + [ + { + name: JobName.LIBRARY_QUEUE_CLEANUP, + data: {}, + }, + ], + [ + { + name: JobName.LIBRARY_SCAN, + data: { + id: libraryStub.externalLibrary1.id, + refreshModifiedFiles: false, + refreshAllFiles: true, + }, + }, + ], + ]); + }); + }); + + describe('handleEmptyTrash', () => { + it('can queue trash deletion jobs', async () => { + assetMock.getWith.mockResolvedValue({ items: [assetStub.image1], hasNextPage: false }); + assetMock.getById.mockResolvedValue(assetStub.image1); + + await expect(sut.handleOfflineRemoval({ id: libraryStub.externalLibrary1.id })).resolves.toBe(true); + + expect(jobMock.queue.mock.calls).toEqual([ + [ + { + name: JobName.SEARCH_REMOVE_ASSET, + data: { + ids: [assetStub.image1.id], + }, + }, + ], + [ + { + name: JobName.DELETE_FILES, + data: { + files: [ + assetStub.image1.webpPath, + assetStub.image1.resizePath, + assetStub.image1.encodedVideoPath, + assetStub.image1.sidecarPath, + ], + }, + }, + ], + ]); + }); + }); +}); diff --git a/server/src/domain/library/library.service.ts b/server/src/domain/library/library.service.ts new file mode 100644 index 0000000000..4cce996460 --- /dev/null +++ b/server/src/domain/library/library.service.ts @@ -0,0 +1,468 @@ +import { AssetType, LibraryType } from '@app/infra/entities'; +import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common'; +import { R_OK } from 'node:constants'; +import { Stats } from 'node:fs'; +import path from 'node:path'; +import { basename, parse } from 'path'; +import { AccessCore, IAccessRepository, Permission } from '../access'; +import { IAssetRepository, WithProperty } from '../asset'; +import { AuthUserDto } from '../auth'; +import { usePagination } from '../domain.util'; + +import { ICryptoRepository } from '../crypto'; +import { mimeTypes } from '../domain.constant'; +import { + IBaseJob, + IEntityJob, + IJobRepository, + ILibraryFileJob, + ILibraryRefreshJob, + IOfflineLibraryFileJob, + JobName, + JOBS_ASSET_PAGINATION_SIZE, +} from '../job'; +import { IStorageRepository } from '../storage'; +import { IUserRepository } from '../user'; +import { + CreateLibraryDto, + LibraryResponseDto, + LibraryStatsResponseDto, + mapLibrary, + ScanLibraryDto, + UpdateLibraryDto, +} from './library.dto'; +import { ILibraryRepository } from './library.repository'; + +@Injectable() +export class LibraryService { + readonly logger = new Logger(LibraryService.name); + private access: AccessCore; + + constructor( + @Inject(IAccessRepository) accessRepository: IAccessRepository, + @Inject(IAssetRepository) private assetRepository: IAssetRepository, + @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, + @Inject(IJobRepository) private jobRepository: IJobRepository, + @Inject(ILibraryRepository) private repository: ILibraryRepository, + @Inject(IStorageRepository) private storageRepository: IStorageRepository, + @Inject(IUserRepository) private userRepository: IUserRepository, + ) { + this.access = new AccessCore(accessRepository); + } + + async getStatistics(authUser: AuthUserDto, id: string): Promise { + await this.access.requirePermission(authUser, Permission.LIBRARY_READ, id); + return this.repository.getStatistics(id); + } + + async getCount(authUser: AuthUserDto): Promise { + return this.repository.getCountForUser(authUser.id); + } + + async getAllForUser(authUser: AuthUserDto): Promise { + const libraries = await this.repository.getAllByUserId(authUser.id); + return libraries.map((library) => mapLibrary(library)); + } + + async get(authUser: AuthUserDto, id: string): Promise { + await this.access.requirePermission(authUser, Permission.LIBRARY_READ, id); + const library = await this.findOrFail(id); + return mapLibrary(library); + } + + async handleQueueCleanup(): Promise { + this.logger.debug('Cleaning up any pending library deletions'); + const pendingDeletion = await this.repository.getAllDeleted(); + for (const libraryToDelete of pendingDeletion) { + await this.jobRepository.queue({ name: JobName.LIBRARY_DELETE, data: { id: libraryToDelete.id } }); + } + return true; + } + + async create(authUser: AuthUserDto, dto: CreateLibraryDto): Promise { + switch (dto.type) { + case LibraryType.EXTERNAL: + if (!dto.name) { + dto.name = 'New External Library'; + } + break; + case LibraryType.UPLOAD: + if (!dto.name) { + dto.name = 'New Upload Library'; + } + if (dto.importPaths && dto.importPaths.length > 0) { + throw new BadRequestException('Upload libraries cannot have import paths'); + } + if (dto.exclusionPatterns && dto.exclusionPatterns.length > 0) { + throw new BadRequestException('Upload libraries cannot have exclusion patterns'); + } + break; + } + + const library = await this.repository.create({ + ownerId: authUser.id, + name: dto.name, + type: dto.type, + importPaths: dto.importPaths ?? [], + exclusionPatterns: dto.exclusionPatterns ?? [], + isVisible: dto.isVisible ?? true, + }); + + return mapLibrary(library); + } + + async update(authUser: AuthUserDto, id: string, dto: UpdateLibraryDto): Promise { + await this.access.requirePermission(authUser, Permission.LIBRARY_UPDATE, id); + const library = await this.repository.update({ id, ...dto }); + return mapLibrary(library); + } + + async delete(authUser: AuthUserDto, id: string) { + await this.access.requirePermission(authUser, Permission.LIBRARY_DELETE, id); + + const library = await this.findOrFail(id); + const uploadCount = await this.repository.getUploadLibraryCount(authUser.id); + if (library.type === LibraryType.UPLOAD && uploadCount <= 1) { + throw new BadRequestException('Cannot delete the last upload library'); + } + + await this.repository.softDelete(id); + await this.jobRepository.queue({ name: JobName.LIBRARY_DELETE, data: { id } }); + } + + async handleDeleteLibrary(job: IEntityJob): Promise { + const library = await this.repository.get(job.id, true); + if (!library) { + return false; + } + + // TODO use pagination + const assetIds = await this.repository.getAssetIds(job.id); + this.logger.debug(`Will delete ${assetIds.length} asset(s) in library ${job.id}`); + // TODO queue a job for asset deletion + await this.deleteAssets(assetIds); + this.logger.log(`Deleting library ${job.id}`); + await this.repository.delete(job.id); + return true; + } + + async handleAssetRefresh(job: ILibraryFileJob) { + const assetPath = path.normalize(job.assetPath); + + const user = await this.userRepository.get(job.ownerId); + if (!user?.externalPath) { + this.logger.warn('User has no external path set, cannot import asset'); + return false; + } + + if (!path.normalize(assetPath).match(new RegExp(`^${user.externalPath}`))) { + this.logger.error("Asset must be within the user's external path"); + return false; + } + + const existingAssetEntity = await this.assetRepository.getByLibraryIdAndOriginalPath(job.id, assetPath); + + let stats: Stats; + try { + stats = await this.storageRepository.stat(assetPath); + } catch (error: Error | any) { + // Can't access file, probably offline + if (existingAssetEntity) { + // Mark asset as offline + this.logger.debug(`Marking asset as offline: ${assetPath}`); + + await this.assetRepository.save({ id: existingAssetEntity.id, isOffline: true }); + return true; + } else { + // File can't be accessed and does not already exist in db + throw new BadRequestException("Can't access file", { cause: error }); + } + } + + let doImport = false; + let doRefresh = false; + + if (job.forceRefresh) { + doRefresh = true; + } + + if (!existingAssetEntity) { + // This asset is new to us, read it from disk + this.logger.debug(`Importing new asset: ${assetPath}`); + doImport = true; + } else if (stats.mtime.toISOString() !== existingAssetEntity.fileModifiedAt.toISOString()) { + // File modification time has changed since last time we checked, re-read from disk + this.logger.debug( + `File modification time has changed, re-importing asset: ${assetPath}. Old mtime: ${existingAssetEntity.fileModifiedAt}. New mtime: ${stats.mtime}`, + ); + doRefresh = true; + } else if (!job.forceRefresh && stats && !existingAssetEntity.isOffline) { + // Asset exists on disk and in db and mtime has not changed. Also, we are not forcing refresn. Therefore, do nothing + this.logger.debug(`Asset already exists in database and on disk, will not import: ${assetPath}`); + } + + if (stats && existingAssetEntity?.isOffline) { + // File was previously offline but is now online + this.logger.debug(`Marking previously-offline asset as online: ${assetPath}`); + await this.assetRepository.save({ id: existingAssetEntity.id, isOffline: false }); + doRefresh = true; + } + + if (!doImport && !doRefresh) { + // If we don't import, exit here + return true; + } + + let assetType: AssetType; + + if (mimeTypes.isImage(assetPath)) { + assetType = AssetType.IMAGE; + } else if (mimeTypes.isVideo(assetPath)) { + assetType = AssetType.VIDEO; + } else { + throw new BadRequestException(`Unsupported file type ${assetPath}`); + } + + // TODO: doesn't xmp replace the file extension? Will need investigation + let sidecarPath: string | null = null; + if (await this.storageRepository.checkFileExists(`${assetPath}.xmp`, R_OK)) { + sidecarPath = `${assetPath}.xmp`; + } + + const deviceAssetId = `${basename(assetPath)}`.replace(/\s+/g, ''); + + const pathHash = this.cryptoRepository.hashSha1(`path:${assetPath}`); + + let assetId; + if (doImport) { + const library = await this.repository.get(job.id, true); + if (library?.deletedAt) { + this.logger.error('Cannot import asset into deleted library'); + return false; + } + + // TODO: In wait of refactoring the domain asset service, this function is just manually written like this + const addedAsset = await this.assetRepository.create({ + ownerId: job.ownerId, + libraryId: job.id, + checksum: pathHash, + originalPath: assetPath, + deviceAssetId: deviceAssetId, + deviceId: 'Library Import', + fileCreatedAt: stats.ctime, + fileModifiedAt: stats.mtime, + type: assetType, + originalFileName: parse(assetPath).name, + sidecarPath, + isReadOnly: true, + isExternal: true, + }); + assetId = addedAsset.id; + } else if (doRefresh && existingAssetEntity) { + assetId = existingAssetEntity.id; + await this.assetRepository.updateAll([existingAssetEntity.id], { + fileCreatedAt: stats.ctime, + fileModifiedAt: stats.mtime, + }); + } else { + // Not importing and not refreshing, do nothing + return true; + } + + this.logger.debug(`Queuing metadata extraction for: ${assetPath}`); + + await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: assetId, source: 'upload' } }); + + if (assetType === AssetType.VIDEO) { + await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: { id: assetId } }); + } + + return true; + } + + async queueScan(authUser: AuthUserDto, id: string, dto: ScanLibraryDto) { + await this.access.requirePermission(authUser, Permission.LIBRARY_UPDATE, id); + + const library = await this.repository.get(id); + if (!library || library.type !== LibraryType.EXTERNAL) { + throw new BadRequestException('Can only refresh external libraries'); + } + + await this.jobRepository.queue({ + name: JobName.LIBRARY_SCAN, + data: { + id, + refreshModifiedFiles: dto.refreshModifiedFiles ?? false, + refreshAllFiles: dto.refreshAllFiles ?? false, + }, + }); + } + + async queueRemoveOffline(authUser: AuthUserDto, id: string) { + this.logger.verbose(`Removing offline files from library: ${id}`); + await this.access.requirePermission(authUser, Permission.LIBRARY_UPDATE, id); + + await this.jobRepository.queue({ + name: JobName.LIBRARY_REMOVE_OFFLINE, + data: { + id, + }, + }); + } + + async handleQueueAllScan(job: IBaseJob): Promise { + this.logger.debug(`Refreshing all external libraries: force=${job.force}`); + + // Queue cleanup + await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_CLEANUP, data: {} }); + + // Queue all library refresh + const libraries = await this.repository.getAll(true, LibraryType.EXTERNAL); + for (const library of libraries) { + await this.jobRepository.queue({ + name: JobName.LIBRARY_SCAN, + data: { + id: library.id, + refreshModifiedFiles: !job.force, + refreshAllFiles: job.force ?? false, + }, + }); + } + return true; + } + + async handleOfflineRemoval(job: IEntityJob): Promise { + const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { + return this.assetRepository.getWith(pagination, WithProperty.IS_OFFLINE, job.id); + }); + + const assetIds: string[] = []; + + for await (const assets of assetPagination) { + for (const asset of assets) { + assetIds.push(asset.id); + } + } + + this.logger.verbose(`Found ${assetIds.length} offline assets to remove`); + await this.deleteAssets(assetIds); + return true; + } + + async handleQueueAssetRefresh(job: ILibraryRefreshJob): Promise { + const library = await this.repository.get(job.id); + if (!library || library.type !== LibraryType.EXTERNAL) { + this.logger.warn('Can only refresh external libraries'); + return false; + } + + const user = await this.userRepository.get(library.ownerId); + if (!user?.externalPath) { + this.logger.warn('User has no external path set, cannot refresh library'); + return false; + } + + this.logger.verbose(`Refreshing library: ${job.id}`); + const crawledAssetPaths = ( + await this.storageRepository.crawl({ + pathsToCrawl: library.importPaths, + exclusionPatterns: library.exclusionPatterns, + }) + ) + .map(path.normalize) + .filter((assetPath) => + // Filter out paths that are not within the user's external path + assetPath.match(new RegExp(`^${user.externalPath}`)), + ); + + this.logger.debug(`Found ${crawledAssetPaths.length} assets when crawling import paths ${library.importPaths}`); + const assetsInLibrary = await this.assetRepository.getByLibraryId([job.id]); + const offlineAssets = assetsInLibrary.filter((asset) => !crawledAssetPaths.includes(asset.originalPath)); + this.logger.debug(`${offlineAssets.length} assets in library are not present on disk and will be marked offline`); + + for (const offlineAsset of offlineAssets) { + const offlineJobData: IOfflineLibraryFileJob = { + id: job.id, + assetPath: offlineAsset.originalPath, + }; + + await this.jobRepository.queue({ name: JobName.LIBRARY_MARK_ASSET_OFFLINE, data: offlineJobData }); + } + + if (crawledAssetPaths.length > 0) { + let filteredPaths: string[] = []; + if (job.refreshAllFiles || job.refreshModifiedFiles) { + filteredPaths = crawledAssetPaths; + } else { + const existingPaths = await this.repository.getOnlineAssetPaths(job.id); + this.logger.debug(`Found ${existingPaths.length} existing asset(s) in library ${job.id}`); + + filteredPaths = crawledAssetPaths.filter((assetPath) => !existingPaths.includes(assetPath)); + this.logger.debug(`After db comparison, ${filteredPaths.length} asset(s) remain to be imported`); + } + + for (const assetPath of filteredPaths) { + const libraryJobData: ILibraryFileJob = { + id: job.id, + assetPath: path.normalize(assetPath), + ownerId: library.ownerId, + forceRefresh: job.refreshAllFiles ?? false, + }; + + await this.jobRepository.queue({ name: JobName.LIBRARY_SCAN_ASSET, data: libraryJobData }); + } + } + + await this.repository.update({ id: job.id, refreshedAt: new Date() }); + + return true; + } + + async handleOfflineAsset(job: IOfflineLibraryFileJob): Promise { + const existingAssetEntity = await this.assetRepository.getByLibraryIdAndOriginalPath(job.id, job.assetPath); + + if (existingAssetEntity) { + this.logger.verbose(`Marking asset as offline: ${job.assetPath}`); + await this.assetRepository.save({ id: existingAssetEntity.id, isOffline: true }); + } + + return true; + } + + private async findOrFail(id: string) { + const library = await this.repository.get(id); + if (!library) { + throw new BadRequestException('Library not found'); + } + return library; + } + + private async deleteAssets(assetIds: string[]) { + // TODO: this should be refactored to a centralized asset deletion service + for (const assetId of assetIds) { + const asset = await this.assetRepository.getById(assetId); + this.logger.debug(`Removing asset from library: ${asset.originalPath}`); + + if (asset.faces) { + await Promise.all( + asset.faces.map(({ assetId, personId }) => + this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_FACE, data: { assetId, personId } }), + ), + ); + } + + await this.assetRepository.remove(asset); + await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [asset.id] } }); + + await this.jobRepository.queue({ + name: JobName.DELETE_FILES, + data: { files: [asset.webpPath, asset.resizePath, asset.encodedVideoPath, asset.sidecarPath] }, + }); + + // TODO refactor this to use cascades + if (asset.livePhotoVideoId && !assetIds.includes(asset.livePhotoVideoId)) { + assetIds.push(asset.livePhotoVideoId); + } + } + } +} diff --git a/server/src/domain/storage-template/storage-template.service.ts b/server/src/domain/storage-template/storage-template.service.ts index 8941ef823c..9be6664453 100644 --- a/server/src/domain/storage-template/storage-template.service.ts +++ b/server/src/domain/storage-template/storage-template.service.ts @@ -90,8 +90,9 @@ export class StorageTemplateService { } async moveAsset(asset: AssetEntity, metadata: MoveAssetMetadata) { - if (asset.isReadOnly) { - this.logger.verbose(`Not moving read-only asset: ${asset.originalPath}`); + if (asset.isReadOnly || asset.isExternal) { + // External assets are not affected by storage template + // TODO: shouldn't this only apply to external assets? return; } diff --git a/server/src/domain/storage/storage.repository.ts b/server/src/domain/storage/storage.repository.ts index 62b78094b7..659fe68237 100644 --- a/server/src/domain/storage/storage.repository.ts +++ b/server/src/domain/storage/storage.repository.ts @@ -1,4 +1,6 @@ +import { Stats } from 'fs'; import { Readable } from 'stream'; +import { CrawlOptionsDto } from '../library'; export interface ImmichReadStream { stream: Readable; @@ -30,4 +32,6 @@ export interface IStorageRepository { mkdirSync(filepath: string): void; checkDiskUsage(folder: string): Promise; readdir(folder: string): Promise; + stat(filepath: string): Promise; + crawl(crawlOptions: CrawlOptionsDto): Promise; } diff --git a/server/src/domain/system-config/dto/system-config-job.dto.ts b/server/src/domain/system-config/dto/system-config-job.dto.ts index ce9bcb7e77..b8064a5b37 100644 --- a/server/src/domain/system-config/dto/system-config-job.dto.ts +++ b/server/src/domain/system-config/dto/system-config-job.dto.ts @@ -70,4 +70,10 @@ export class SystemConfigJobDto implements Record { @IsObject() @Type(() => JobSettingsDto) [QueueName.SIDECAR]!: JobSettingsDto; + + @ApiProperty({ type: JobSettingsDto }) + @ValidateNested() + @IsObject() + @Type(() => JobSettingsDto) + [QueueName.LIBRARY]!: JobSettingsDto; } diff --git a/server/src/domain/system-config/system-config.core.ts b/server/src/domain/system-config/system-config.core.ts index feefae0dda..013813edbd 100644 --- a/server/src/domain/system-config/system-config.core.ts +++ b/server/src/domain/system-config/system-config.core.ts @@ -51,6 +51,7 @@ export const defaults = Object.freeze({ [QueueName.RECOGNIZE_FACES]: { concurrency: 2 }, [QueueName.SEARCH]: { concurrency: 5 }, [QueueName.SIDECAR]: { concurrency: 5 }, + [QueueName.LIBRARY]: { concurrency: 1 }, [QueueName.STORAGE_TEMPLATE_MIGRATION]: { concurrency: 5 }, [QueueName.THUMBNAIL_GENERATION]: { concurrency: 5 }, [QueueName.VIDEO_CONVERSION]: { concurrency: 1 }, diff --git a/server/src/domain/system-config/system-config.service.spec.ts b/server/src/domain/system-config/system-config.service.spec.ts index 6718c53f59..328651e2cf 100644 --- a/server/src/domain/system-config/system-config.service.spec.ts +++ b/server/src/domain/system-config/system-config.service.spec.ts @@ -31,6 +31,7 @@ const updatedConfig = Object.freeze({ [QueueName.RECOGNIZE_FACES]: { concurrency: 2 }, [QueueName.SEARCH]: { concurrency: 5 }, [QueueName.SIDECAR]: { concurrency: 5 }, + [QueueName.LIBRARY]: { concurrency: 1 }, [QueueName.STORAGE_TEMPLATE_MIGRATION]: { concurrency: 5 }, [QueueName.THUMBNAIL_GENERATION]: { concurrency: 5 }, [QueueName.VIDEO_CONVERSION]: { concurrency: 1 }, diff --git a/server/src/domain/user/user.core.ts b/server/src/domain/user/user.core.ts index 6d0054aa9e..2034b1b7fa 100644 --- a/server/src/domain/user/user.core.ts +++ b/server/src/domain/user/user.core.ts @@ -1,4 +1,4 @@ -import { UserEntity } from '@app/infra/entities'; +import { LibraryType, UserEntity } from '@app/infra/entities'; import { BadRequestException, ForbiddenException, @@ -11,6 +11,7 @@ import fs from 'fs/promises'; import sanitize from 'sanitize-filename'; import { AuthUserDto } from '../auth'; import { ICryptoRepository } from '../crypto'; +import { ILibraryRepository } from '../library/library.repository'; import { IUserRepository, UserListFilter } from './user.repository'; const SALT_ROUNDS = 10; @@ -18,6 +19,7 @@ const SALT_ROUNDS = 10; export class UserCore { constructor( private userRepository: IUserRepository, + private libraryRepository: ILibraryRepository, private cryptoRepository: ICryptoRepository, ) {} @@ -91,7 +93,19 @@ export class UserCore { if (payload.storageLabel) { payload.storageLabel = sanitize(payload.storageLabel); } - return this.userRepository.create(payload); + + const userEntity = await this.userRepository.create(payload); + await this.libraryRepository.create({ + owner: { id: userEntity.id } as UserEntity, + name: 'Default Library', + assets: [], + type: LibraryType.UPLOAD, + importPaths: [], + exclusionPatterns: [], + isVisible: true, + }); + + return userEntity; } catch (e) { Logger.error(e, 'Create new user'); throw new InternalServerErrorException('Failed to register new user'); diff --git a/server/src/domain/user/user.service.spec.ts b/server/src/domain/user/user.service.spec.ts index 32ffe75758..17b2f09405 100644 --- a/server/src/domain/user/user.service.spec.ts +++ b/server/src/domain/user/user.service.spec.ts @@ -10,6 +10,7 @@ import { newAssetRepositoryMock, newCryptoRepositoryMock, newJobRepositoryMock, + newLibraryRepositoryMock, newStorageRepositoryMock, newUserRepositoryMock, userStub, @@ -20,6 +21,7 @@ import { IAssetRepository } from '../asset'; import { AuthUserDto } from '../auth'; import { ICryptoRepository } from '../crypto'; import { IJobRepository, JobName } from '../job'; +import { ILibraryRepository } from '../library'; import { IStorageRepository } from '../storage'; import { UpdateUserDto } from './dto/update-user.dto'; import { UserResponseDto, mapUser } from './response-dto'; @@ -129,6 +131,7 @@ describe(UserService.name, () => { let albumMock: jest.Mocked; let assetMock: jest.Mocked; let jobMock: jest.Mocked; + let libraryMock: jest.Mocked; let storageMock: jest.Mocked; beforeEach(async () => { @@ -136,10 +139,11 @@ describe(UserService.name, () => { albumMock = newAlbumRepositoryMock(); assetMock = newAssetRepositoryMock(); jobMock = newJobRepositoryMock(); + libraryMock = newLibraryRepositoryMock(); storageMock = newStorageRepositoryMock(); userMock = newUserRepositoryMock(); - sut = new UserService(userMock, cryptoRepositoryMock, albumMock, assetMock, jobMock, storageMock); + sut = new UserService(userMock, cryptoRepositoryMock, libraryMock, albumMock, assetMock, jobMock, storageMock); when(userMock.get).calledWith(adminUser.id).mockResolvedValue(adminUser); when(userMock.get).calledWith(adminUser.id, undefined).mockResolvedValue(adminUser); diff --git a/server/src/domain/user/user.service.ts b/server/src/domain/user/user.service.ts index 46d5350f50..3f35e2c358 100644 --- a/server/src/domain/user/user.service.ts +++ b/server/src/domain/user/user.service.ts @@ -7,6 +7,7 @@ import { IAssetRepository } from '../asset/asset.repository'; import { AuthUserDto } from '../auth'; import { ICryptoRepository } from '../crypto/crypto.repository'; import { IEntityJob, IJobRepository, JobName } from '../job'; +import { ILibraryRepository } from '../library/library.repository'; import { StorageCore, StorageFolder } from '../storage'; import { IStorageRepository } from '../storage/storage.repository'; import { CreateUserDto, UpdateUserDto, UserCountDto } from './dto'; @@ -30,13 +31,13 @@ export class UserService { constructor( @Inject(IUserRepository) private userRepository: IUserRepository, @Inject(ICryptoRepository) cryptoRepository: ICryptoRepository, - + @Inject(ILibraryRepository) libraryRepository: ILibraryRepository, @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, ) { - this.userCore = new UserCore(userRepository, cryptoRepository); + this.userCore = new UserCore(userRepository, libraryRepository, cryptoRepository); } async getAll(authUser: AuthUserDto, isAll: boolean): Promise { diff --git a/server/src/immich/api-v1/asset/asset-repository.ts b/server/src/immich/api-v1/asset/asset-repository.ts index c666e88bdf..722ca3e38b 100644 --- a/server/src/immich/api-v1/asset/asset-repository.ts +++ b/server/src/immich/api-v1/asset/asset-repository.ts @@ -22,7 +22,7 @@ export interface AssetOwnerCheck extends AssetCheck { export interface IAssetRepository { get(id: string): Promise; create( - asset: Omit, + asset: Omit, ): Promise; remove(asset: AssetEntity): Promise; getAllByUserId(userId: string, dto: AssetSearchDto): Promise; @@ -146,6 +146,7 @@ export class AssetRepository implements IAssetRepository { faces: { person: true, }, + library: true, }, }); } diff --git a/server/src/immich/api-v1/asset/asset.core.ts b/server/src/immich/api-v1/asset/asset.core.ts index c5fd0c0515..a736ff79ac 100644 --- a/server/src/immich/api-v1/asset/asset.core.ts +++ b/server/src/immich/api-v1/asset/asset.core.ts @@ -1,5 +1,5 @@ import { AuthUserDto, IJobRepository, JobName, mimeTypes, UploadFile } from '@app/domain'; -import { AssetEntity, UserEntity } from '@app/infra/entities'; +import { AssetEntity, LibraryEntity, UserEntity } from '@app/infra/entities'; import { parse } from 'node:path'; import { IAssetRepository } from './asset-repository'; import { CreateAssetDto, ImportAssetDto } from './dto/create-asset.dto'; @@ -19,6 +19,7 @@ export class AssetCore { ): Promise { const asset = await this.repository.create({ owner: { id: authUser.id } as UserEntity, + library: { id: dto.libraryId } as LibraryEntity, checksum: file.checksum, originalPath: file.originalPath, @@ -45,6 +46,8 @@ export class AssetCore { faces: [], sidecarPath: sidecarPath || null, isReadOnly: dto.isReadOnly ?? false, + isExternal: dto.isExternal ?? false, + isOffline: dto.isOffline ?? false, }); await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id, source: 'upload' } }); diff --git a/server/src/immich/api-v1/asset/asset.service.spec.ts b/server/src/immich/api-v1/asset/asset.service.spec.ts index 8145888ce1..02eb5ece42 100644 --- a/server/src/immich/api-v1/asset/asset.service.spec.ts +++ b/server/src/immich/api-v1/asset/asset.service.spec.ts @@ -1,14 +1,16 @@ -import { ICryptoRepository, IJobRepository, IStorageRepository, JobName } from '@app/domain'; -import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities'; +import { ICryptoRepository, IJobRepository, ILibraryRepository, IStorageRepository, JobName } from '@app/domain'; +import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType, ExifEntity } from '@app/infra/entities'; import { BadRequestException } from '@nestjs/common'; import { IAccessRepositoryMock, assetStub, authStub, fileStub, + libraryStub, newAccessRepositoryMock, newCryptoRepositoryMock, newJobRepositoryMock, + newLibraryRepositoryMock, newStorageRepositoryMock, } from '@test'; import { when } from 'jest-when'; @@ -27,6 +29,7 @@ const _getCreateAssetDto = (): CreateAssetDto => { createAssetDto.isFavorite = false; createAssetDto.isArchived = false; createAssetDto.duration = '0:00:00.000000'; + createAssetDto.libraryId = 'libraryId'; return createAssetDto; }; @@ -89,6 +92,7 @@ describe('AssetService', () => { let cryptoMock: jest.Mocked; let jobMock: jest.Mocked; let storageMock: jest.Mocked; + let libraryMock: jest.Mocked; beforeEach(() => { assetRepositoryMock = { @@ -111,8 +115,9 @@ describe('AssetService', () => { cryptoMock = newCryptoRepositoryMock(); jobMock = newJobRepositoryMock(); storageMock = newStorageRepositoryMock(); + libraryMock = newLibraryRepositoryMock(); - sut = new AssetService(accessMock, assetRepositoryMock, a, cryptoMock, jobMock, storageMock); + sut = new AssetService(accessMock, assetRepositoryMock, a, cryptoMock, jobMock, libraryMock, storageMock); when(assetRepositoryMock.get) .calledWith(assetStub.livePhotoStillAsset.id) @@ -149,7 +154,7 @@ describe('AssetService', () => { }; const dto = _getCreateAssetDto(); const error = new QueryFailedError('', [], ''); - (error as any).constraint = 'UQ_userid_checksum'; + (error as any).constraint = ASSET_CHECKSUM_CONSTRAINT; assetRepositoryMock.create.mockRejectedValue(error); assetRepositoryMock.getAssetsByChecksums.mockResolvedValue([_getAsset_1()]); @@ -166,7 +171,7 @@ describe('AssetService', () => { it('should handle a live photo', async () => { const dto = _getCreateAssetDto(); const error = new QueryFailedError('', [], ''); - (error as any).constraint = 'UQ_userid_checksum'; + (error as any).constraint = ASSET_CHECKSUM_CONSTRAINT; assetRepositoryMock.create.mockResolvedValueOnce(assetStub.livePhotoMotionAsset); assetRepositoryMock.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset); @@ -217,7 +222,10 @@ describe('AssetService', () => { }); it('should return failed status a delete fails', async () => { - assetRepositoryMock.get.mockResolvedValue({ id: 'asset1' } as AssetEntity); + assetRepositoryMock.get.mockResolvedValue({ + id: 'asset1', + library: libraryStub.uploadLibrary1, + } as AssetEntity); assetRepositoryMock.remove.mockRejectedValue('delete failed'); accessMock.asset.hasOwnerAccess.mockResolvedValue(true); @@ -261,6 +269,7 @@ describe('AssetService', () => { originalPath: 'original-path-1', resizePath: 'resize-path-1', webpPath: 'web-path-1', + library: libraryStub.uploadLibrary1, }; const asset2 = { @@ -269,6 +278,17 @@ describe('AssetService', () => { resizePath: 'resize-path-2', webpPath: 'web-path-2', encodedVideoPath: 'encoded-video-path-2', + library: libraryStub.uploadLibrary1, + }; + + // Can't be deleted since it's external + const asset3 = { + id: 'asset3', + originalPath: 'original-path-3', + resizePath: 'resize-path-3', + webpPath: 'web-path-3', + encodedVideoPath: 'encoded-video-path-2', + library: libraryStub.externalLibrary1, }; when(assetRepositoryMock.get) @@ -277,12 +297,16 @@ describe('AssetService', () => { when(assetRepositoryMock.get) .calledWith(asset2.id) .mockResolvedValue(asset2 as AssetEntity); + when(assetRepositoryMock.get) + .calledWith(asset3.id) + .mockResolvedValue(asset3 as AssetEntity); accessMock.asset.hasOwnerAccess.mockResolvedValue(true); - await expect(sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'] })).resolves.toEqual([ + await expect(sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2', 'asset3'] })).resolves.toEqual([ { id: 'asset1', status: 'SUCCESS' }, { id: 'asset2', status: 'SUCCESS' }, + { id: 'asset3', status: 'FAILED' }, ]); expect(jobMock.queue.mock.calls).toEqual([ @@ -349,6 +373,7 @@ describe('AssetService', () => { ..._getCreateAssetDto(), assetPath: '/data/user1/fake_path/asset_1.jpeg', isReadOnly: true, + libraryId: 'library-id', }), ).resolves.toEqual({ duplicate: false, id: 'asset-id' }); @@ -357,7 +382,7 @@ describe('AssetService', () => { it('should handle a duplicate if originalPath already exists', async () => { const error = new QueryFailedError('', [], ''); - (error as any).constraint = 'UQ_userid_checksum'; + (error as any).constraint = ASSET_CHECKSUM_CONSTRAINT; assetRepositoryMock.create.mockRejectedValue(error); assetRepositoryMock.getAssetsByChecksums.mockResolvedValue([assetStub.image]); @@ -369,6 +394,7 @@ describe('AssetService', () => { ..._getCreateAssetDto(), assetPath: '/data/user1/fake_path/asset_1.jpeg', isReadOnly: true, + libraryId: 'library-id', }), ).resolves.toEqual({ duplicate: true, id: 'asset-id' }); diff --git a/server/src/immich/api-v1/asset/asset.service.ts b/server/src/immich/api-v1/asset/asset.service.ts index c6013e2f7e..91ae3ce73a 100644 --- a/server/src/immich/api-v1/asset/asset.service.ts +++ b/server/src/immich/api-v1/asset/asset.service.ts @@ -6,6 +6,7 @@ import { IAccessRepository, ICryptoRepository, IJobRepository, + ILibraryRepository, IStorageRepository, JobName, mapAsset, @@ -14,7 +15,7 @@ import { Permission, UploadFile, } from '@app/domain'; -import { AssetEntity, AssetType } from '@app/infra/entities'; +import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType, LibraryType } from '@app/infra/entities'; import { BadRequestException, Inject, @@ -65,6 +66,7 @@ export class AssetService { @InjectRepository(AssetEntity) private assetRepository: Repository, @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, + @Inject(ILibraryRepository) private libraryRepository: ILibraryRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, ) { this.assetCore = new AssetCore(_assetRepository, jobRepository); @@ -93,6 +95,16 @@ export class AssetService { livePhotoAsset = await this.assetCore.create(authUser, livePhotoDto, livePhotoFile); } + if (!dto.libraryId) { + // No library given, fall back to default upload library + const defaultUploadLibrary = await this.libraryRepository.getDefaultUploadLibrary(authUser.id); + + if (!defaultUploadLibrary) { + throw new InternalServerErrorException('Cannot find default upload library for user ' + authUser.id); + } + dto.libraryId = defaultUploadLibrary.id; + } + const asset = await this.assetCore.create(authUser, dto, file, livePhotoAsset?.id, sidecarFile?.originalPath); return { id: asset.id, duplicate: false }; @@ -104,7 +116,7 @@ export class AssetService { }); // handle duplicates with a success response - if (error instanceof QueryFailedError && (error as any).constraint === 'UQ_userid_checksum') { + if (error instanceof QueryFailedError && (error as any).constraint === ASSET_CHECKSUM_CONSTRAINT) { const checksums = [file.checksum, livePhotoFile?.checksum].filter((checksum): checksum is Buffer => !!checksum); const [duplicate] = await this._assetRepository.getAssetsByChecksums(authUser.id, checksums); return { id: duplicate.id, duplicate: true }; @@ -156,22 +168,11 @@ export class AssetService { return { id: asset.id, duplicate: false }; } catch (error: QueryFailedError | Error | any) { // handle duplicates with a success response - if (error instanceof QueryFailedError && (error as any).constraint === 'UQ_userid_checksum') { + if (error instanceof QueryFailedError && (error as any).constraint === ASSET_CHECKSUM_CONSTRAINT) { const [duplicate] = await this._assetRepository.getAssetsByChecksums(authUser.id, [assetFile.checksum]); return { id: duplicate.id, duplicate: true }; } - if (error instanceof QueryFailedError && (error as any).constraint === 'UQ_4ed4f8052685ff5b1e7ca1058ba') { - const duplicate = await this._assetRepository.getByOriginalPath(dto.assetPath); - if (duplicate) { - if (duplicate.ownerId === authUser.id) { - return { id: duplicate.id, duplicate: true }; - } - - throw new BadRequestException('Path in use by another user'); - } - } - this.logger.error(`Error importing file ${error}`, error?.stack); throw new BadRequestException(`Error importing file`, `${error}`); } @@ -183,7 +184,7 @@ export class AssetService { public async getAllAssets(authUser: AuthUserDto, dto: AssetSearchDto): Promise { const userId = dto.userId || authUser.id; - await this.access.requirePermission(authUser, Permission.LIBRARY_READ, userId); + await this.access.requirePermission(authUser, Permission.TIMELINE_READ, userId); const assets = await this._assetRepository.getAllByUserId(userId, dto); return assets.map((asset) => mapAsset(asset)); } @@ -258,7 +259,8 @@ export class AssetService { } const asset = await this._assetRepository.get(id); - if (!asset) { + if (!asset || !asset.library || asset.library.type === LibraryType.EXTERNAL) { + // We don't allow deletions assets belong to an external library result.push({ id, status: DeleteAssetStatusEnum.FAILED }); continue; } @@ -291,7 +293,8 @@ export class AssetService { if (asset.livePhotoVideoId && !ids.includes(asset.livePhotoVideoId)) { ids.push(asset.livePhotoVideoId); } - } catch { + } catch (error) { + this.logger.error(`Error deleting asset ${id}`, error); result.push({ id, status: DeleteAssetStatusEnum.FAILED }); } } diff --git a/server/src/immich/api-v1/asset/dto/create-asset.dto.ts b/server/src/immich/api-v1/asset/dto/create-asset.dto.ts index ac7aa4ddcb..8e6fc4f120 100644 --- a/server/src/immich/api-v1/asset/dto/create-asset.dto.ts +++ b/server/src/immich/api-v1/asset/dto/create-asset.dto.ts @@ -1,4 +1,4 @@ -import { Optional, toBoolean, UploadFieldName } from '@app/domain'; +import { Optional, toBoolean, UploadFieldName, ValidateUUID } from '@app/domain'; import { ApiProperty } from '@nestjs/swagger'; import { Transform, Type } from 'class-transformer'; import { IsBoolean, IsDate, IsNotEmpty, IsString } from 'class-validator'; @@ -39,13 +39,24 @@ export class CreateAssetBase { @Optional() @IsString() duration?: string; + + @Optional() + @IsBoolean() + isExternal?: boolean; + + @Optional() + @IsBoolean() + isOffline?: boolean; } export class CreateAssetDto extends CreateAssetBase { @Optional() @IsBoolean() @Transform(toBoolean) - isReadOnly?: boolean = false; + isReadOnly?: boolean; + + @ValidateUUID({ optional: true }) + libraryId?: string; // The properties below are added to correctly generate the API docs // and client SDKs. Validation should be handled in the controller. @@ -65,6 +76,9 @@ export class ImportAssetDto extends CreateAssetBase { @Transform(toBoolean) isReadOnly?: boolean = true; + @ValidateUUID() + libraryId?: string; + @IsString() @IsNotEmpty() assetPath!: string; diff --git a/server/src/immich/app.module.ts b/server/src/immich/app.module.ts index a2b01cf8c8..306d39a17d 100644 --- a/server/src/immich/app.module.ts +++ b/server/src/immich/app.module.ts @@ -19,6 +19,7 @@ import { AuditController, AuthController, JobController, + LibraryController, OAuthController, PartnerController, PersonController, @@ -46,6 +47,7 @@ import { AuditController, AuthController, JobController, + LibraryController, OAuthController, PartnerController, SearchController, diff --git a/server/src/immich/controllers/index.ts b/server/src/immich/controllers/index.ts index b28e82ecbf..fd6f0b01ef 100644 --- a/server/src/immich/controllers/index.ts +++ b/server/src/immich/controllers/index.ts @@ -5,6 +5,7 @@ export * from './asset.controller'; export * from './audit.controller'; export * from './auth.controller'; export * from './job.controller'; +export * from './library.controller'; export * from './oauth.controller'; export * from './partner.controller'; export * from './person.controller'; diff --git a/server/src/immich/controllers/library.controller.ts b/server/src/immich/controllers/library.controller.ts new file mode 100644 index 0000000000..23b1ec7fcc --- /dev/null +++ b/server/src/immich/controllers/library.controller.ts @@ -0,0 +1,69 @@ +import { + AuthUserDto, + CreateLibraryDto as CreateDto, + LibraryService, + LibraryStatsResponseDto, + LibraryResponseDto as ResponseDto, + ScanLibraryDto, + UpdateLibraryDto as UpdateDto, +} from '@app/domain'; +import { Body, Controller, Delete, Get, Param, Post, Put } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { AuthUser, Authenticated } from '../app.guard'; +import { UseValidation } from '../app.utils'; +import { UUIDParamDto } from './dto/uuid-param.dto'; + +@ApiTags('Library') +@Controller('library') +@Authenticated() +@UseValidation() +export class LibraryController { + constructor(private service: LibraryService) {} + + @Get() + getAllForUser(@AuthUser() authUser: AuthUserDto): Promise { + return this.service.getAllForUser(authUser); + } + + @Post() + createLibrary(@AuthUser() authUser: AuthUserDto, @Body() dto: CreateDto): Promise { + return this.service.create(authUser, dto); + } + + @Put(':id') + updateLibrary( + @AuthUser() authUser: AuthUserDto, + @Param() { id }: UUIDParamDto, + @Body() dto: UpdateDto, + ): Promise { + return this.service.update(authUser, id, dto); + } + + @Get(':id') + getLibraryInfo(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.get(authUser, id); + } + + @Delete(':id') + deleteLibrary(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.delete(authUser, id); + } + + @Get(':id/statistics') + getLibraryStatistics( + @AuthUser() authUser: AuthUserDto, + @Param() { id }: UUIDParamDto, + ): Promise { + return this.service.getStatistics(authUser, id); + } + + @Post(':id/scan') + scanLibrary(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto, @Body() dto: ScanLibraryDto) { + return this.service.queueScan(authUser, id, dto); + } + + @Post(':id/removeOffline') + removeOfflineFiles(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto) { + return this.service.queueRemoveOffline(authUser, id); + } +} diff --git a/server/src/infra/entities/asset.entity.ts b/server/src/infra/entities/asset.entity.ts index 27d040bbb4..c9d5743d4f 100644 --- a/server/src/infra/entities/asset.entity.ts +++ b/server/src/infra/entities/asset.entity.ts @@ -10,19 +10,25 @@ import { OneToMany, OneToOne, PrimaryGeneratedColumn, - Unique, UpdateDateColumn, } from 'typeorm'; import { AlbumEntity } from './album.entity'; import { AssetFaceEntity } from './asset-face.entity'; import { ExifEntity } from './exif.entity'; +import { LibraryEntity } from './library.entity'; import { SharedLinkEntity } from './shared-link.entity'; import { SmartInfoEntity } from './smart-info.entity'; import { TagEntity } from './tag.entity'; import { UserEntity } from './user.entity'; +export const ASSET_CHECKSUM_CONSTRAINT = 'UQ_assets_owner_library_checksum'; + @Entity('assets') -@Unique('UQ_userid_checksum', ['owner', 'checksum']) +// Checksums must be unique per user and library +@Index(ASSET_CHECKSUM_CONSTRAINT, ['owner', 'library', 'checksum'], { + unique: true, +}) +// For all assets, each originalpath must be unique per user and library export class AssetEntity { @PrimaryGeneratedColumn('uuid') id!: string; @@ -36,13 +42,19 @@ export class AssetEntity { @Column() ownerId!: string; + @ManyToOne(() => LibraryEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false }) + library!: LibraryEntity; + + @Column() + libraryId!: string; + @Column() deviceId!: string; @Column() type!: AssetType; - @Column({ unique: true }) + @Column() originalPath!: string; @Column({ type: 'varchar', nullable: true }) @@ -75,9 +87,15 @@ export class AssetEntity { @Column({ type: 'boolean', default: false }) isArchived!: boolean; + @Column({ type: 'boolean', default: false }) + isExternal!: boolean; + @Column({ type: 'boolean', default: false }) isReadOnly!: boolean; + @Column({ type: 'boolean', default: false }) + isOffline!: boolean; + @Column({ type: 'bytea' }) @Index() checksum!: Buffer; // sha1 checksum diff --git a/server/src/infra/entities/index.ts b/server/src/infra/entities/index.ts index c2a7ad7975..72d41f287a 100644 --- a/server/src/infra/entities/index.ts +++ b/server/src/infra/entities/index.ts @@ -4,6 +4,7 @@ import { AssetFaceEntity } from './asset-face.entity'; import { AssetEntity } from './asset.entity'; import { AuditEntity } from './audit.entity'; import { ExifEntity } from './exif.entity'; +import { LibraryEntity } from './library.entity'; import { PartnerEntity } from './partner.entity'; import { PersonEntity } from './person.entity'; import { SharedLinkEntity } from './shared-link.entity'; @@ -19,6 +20,7 @@ export * from './asset-face.entity'; export * from './asset.entity'; export * from './audit.entity'; export * from './exif.entity'; +export * from './library.entity'; export * from './partner.entity'; export * from './person.entity'; export * from './shared-link.entity'; @@ -43,4 +45,5 @@ export const databaseEntities = [ TagEntity, UserEntity, UserTokenEntity, + LibraryEntity, ]; diff --git a/server/src/infra/entities/library.entity.ts b/server/src/infra/entities/library.entity.ts new file mode 100644 index 0000000000..bf5f444abb --- /dev/null +++ b/server/src/infra/entities/library.entity.ts @@ -0,0 +1,61 @@ +import { + Column, + CreateDateColumn, + DeleteDateColumn, + Entity, + JoinTable, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { AssetEntity } from './asset.entity'; +import { UserEntity } from './user.entity'; + +@Entity('libraries') +export class LibraryEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column() + name!: string; + + @OneToMany(() => AssetEntity, (asset) => asset.library) + @JoinTable() + assets!: AssetEntity[]; + + @ManyToOne(() => UserEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false }) + owner!: UserEntity; + + @Column() + ownerId!: string; + + @Column() + type!: LibraryType; + + @Column('text', { array: true }) + importPaths!: string[]; + + @Column('text', { array: true }) + exclusionPatterns!: string[]; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt!: Date; + + @DeleteDateColumn({ type: 'timestamptz' }) + deletedAt?: Date; + + @Column({ type: 'timestamptz', nullable: true }) + refreshedAt!: Date | null; + + @Column({ type: 'boolean', default: true }) + isVisible!: boolean; +} + +export enum LibraryType { + UPLOAD = 'UPLOAD', + EXTERNAL = 'EXTERNAL', +} diff --git a/server/src/infra/entities/system-config.entity.ts b/server/src/infra/entities/system-config.entity.ts index dac332564c..38aa3a046a 100644 --- a/server/src/infra/entities/system-config.entity.ts +++ b/server/src/infra/entities/system-config.entity.ts @@ -42,6 +42,7 @@ export enum SystemConfigKey { JOB_STORAGE_TEMPLATE_MIGRATION_CONCURRENCY = 'job.storageTemplateMigration.concurrency', JOB_SEARCH_CONCURRENCY = 'job.search.concurrency', JOB_SIDECAR_CONCURRENCY = 'job.sidecar.concurrency', + JOB_LIBRARY_CONCURRENCY = 'job.library.concurrency', MACHINE_LEARNING_ENABLED = 'machineLearning.enabled', MACHINE_LEARNING_URL = 'machineLearning.url', diff --git a/server/src/infra/infra.module.ts b/server/src/infra/infra.module.ts index 98d4387ebe..1159579578 100644 --- a/server/src/infra/infra.module.ts +++ b/server/src/infra/infra.module.ts @@ -9,6 +9,7 @@ import { IGeocodingRepository, IJobRepository, IKeyRepository, + ILibraryRepository, IMachineLearningRepository, IMediaRepository, immichAppConfig, @@ -43,6 +44,7 @@ import { FilesystemProvider, GeocodingRepository, JobRepository, + LibraryRepository, MachineLearningRepository, MediaRepository, PartnerRepository, @@ -66,6 +68,7 @@ const providers: Provider[] = [ { provide: IFaceRepository, useClass: FaceRepository }, { provide: IGeocodingRepository, useClass: GeocodingRepository }, { provide: IJobRepository, useClass: JobRepository }, + { provide: ILibraryRepository, useClass: LibraryRepository }, { provide: IKeyRepository, useClass: APIKeyRepository }, { provide: IMachineLearningRepository, useClass: MachineLearningRepository }, { provide: IPartnerRepository, useClass: PartnerRepository }, diff --git a/server/src/infra/migrations/1688392120838-AddLibraryTable.ts b/server/src/infra/migrations/1688392120838-AddLibraryTable.ts new file mode 100644 index 0000000000..53a6f780bf --- /dev/null +++ b/server/src/infra/migrations/1688392120838-AddLibraryTable.ts @@ -0,0 +1,57 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddLibraries1688392120838 implements MigrationInterface { + name = 'AddLibraryTable1688392120838'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "assets" DROP CONSTRAINT "UQ_userid_checksum"`); + await queryRunner.query( + `CREATE TABLE "libraries" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "ownerId" uuid NOT NULL, "type" character varying NOT NULL, "importPaths" text array NOT NULL, "exclusionPatterns" text array NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP WITH TIME ZONE, "refreshedAt" TIMESTAMP WITH TIME ZONE, "isVisible" boolean NOT NULL DEFAULT true, CONSTRAINT "PK_505fedfcad00a09b3734b4223de" PRIMARY KEY ("id"))`, + ); + await queryRunner.query(`ALTER TABLE "assets" ADD "isOffline" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "assets" ADD "libraryId" uuid`); + await queryRunner.query(`ALTER TABLE "assets" DROP CONSTRAINT "UQ_4ed4f8052685ff5b1e7ca1058ba"`); + await queryRunner.query(`ALTER TABLE "assets" ADD "isExternal" boolean NOT NULL DEFAULT false`); + + await queryRunner.query( + `CREATE UNIQUE INDEX "UQ_assets_owner_library_checksum" on "assets" ("ownerId", "libraryId", checksum)`, + ); + await queryRunner.query( + `ALTER TABLE "libraries" ADD CONSTRAINT "FK_0f6fc2fb195f24d19b0fb0d57c1" FOREIGN KEY ("ownerId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE`, + ); + await queryRunner.query( + `ALTER TABLE "assets" ADD CONSTRAINT "FK_9977c3c1de01c3d848039a6b90c" FOREIGN KEY ("libraryId") REFERENCES "libraries"("id") ON DELETE CASCADE ON UPDATE CASCADE`, + ); + + // Create default library for each user and assign all assets to it + const userIds: string[] = (await queryRunner.query(`SELECT id FROM "users"`)).map((user: any) => user.id); + + for (const userId of userIds) { + await queryRunner.query( + `INSERT INTO "libraries" ("name", "ownerId", "type", "importPaths", "exclusionPatterns") VALUES ('Default Library', '${userId}', 'UPLOAD', '{}', '{}')`, + ); + + await queryRunner.query( + `UPDATE "assets" SET "libraryId" = (SELECT id FROM "libraries" WHERE "ownerId" = '${userId}' LIMIT 1) WHERE "ownerId" = '${userId}'`, + ); + } + + await queryRunner.query(`ALTER TABLE "assets" ALTER COLUMN "libraryId" SET NOT NULL`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "assets" ALTER COLUMN "libraryId" DROP NOT NULL`); + await queryRunner.query(`ALTER TABLE "assets" DROP CONSTRAINT "FK_9977c3c1de01c3d848039a6b90c"`); + await queryRunner.query(`ALTER TABLE "libraries" DROP CONSTRAINT "FK_0f6fc2fb195f24d19b0fb0d57c1"`); + await queryRunner.query(`DROP INDEX "UQ_assets_owner_library_checksum"`); + await queryRunner.query(`ALTER TABLE "assets" DROP CONSTRAINT "UQ_owner_library_originalpath"`); + await queryRunner.query( + `ALTER TABLE "assets" ADD CONSTRAINT "UQ_4ed4f8052685ff5b1e7ca1058ba" UNIQUE ("originalPath")`, + ); + await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "libraryId"`); + await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "isOffline"`); + await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "isExternal"`); + await queryRunner.query(`DROP TABLE "libraries"`); + await queryRunner.query(`ALTER TABLE "assets" ADD CONSTRAINT "UQ_userid_checksum" UNIQUE ("ownerId", "checksum")`); + } +} diff --git a/server/src/infra/repositories/access.repository.ts b/server/src/infra/repositories/access.repository.ts index 91706cb2c0..94eecbcb8f 100644 --- a/server/src/infra/repositories/access.repository.ts +++ b/server/src/infra/repositories/access.repository.ts @@ -1,7 +1,7 @@ import { IAccessRepository } from '@app/domain'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { AlbumEntity, AssetEntity, PartnerEntity, PersonEntity, SharedLinkEntity } from '../entities'; +import { AlbumEntity, AssetEntity, LibraryEntity, PartnerEntity, PersonEntity, SharedLinkEntity } from '../entities'; export class AccessRepository implements IAccessRepository { constructor( @@ -10,9 +10,29 @@ export class AccessRepository implements IAccessRepository { @InjectRepository(PartnerEntity) private partnerRepository: Repository, @InjectRepository(PersonEntity) private personRepository: Repository, @InjectRepository(SharedLinkEntity) private sharedLinkRepository: Repository, + @InjectRepository(LibraryEntity) private libraryRepository: Repository, ) {} library = { + hasOwnerAccess: (userId: string, libraryId: string): Promise => { + return this.libraryRepository.exist({ + where: { + id: libraryId, + ownerId: userId, + }, + }); + }, + hasPartnerAccess: (userId: string, partnerId: string): Promise => { + return this.partnerRepository.exist({ + where: { + sharedWithId: userId, + sharedById: partnerId, + }, + }); + }, + }; + + timeline = { hasPartnerAccess: (userId: string, partnerId: string): Promise => { return this.partnerRepository.exist({ where: { diff --git a/server/src/infra/repositories/asset.repository.ts b/server/src/infra/repositories/asset.repository.ts index f4ecba950c..1d0044fa62 100644 --- a/server/src/infra/repositories/asset.repository.ts +++ b/server/src/infra/repositories/asset.repository.ts @@ -38,6 +38,12 @@ export class AssetRepository implements IAssetRepository { await this.exifRepository.upsert(exif, { conflictPaths: ['assetId'] }); } + create( + asset: Omit, + ): Promise { + return this.repository.save(asset); + } + getByDate(ownerId: string, date: Date): Promise { // For reference of a correct approach although slower @@ -85,6 +91,7 @@ export class AssetRepository implements IAssetRepository { }, }); } + async deleteAll(ownerId: string): Promise { await this.repository.delete({ ownerId }); } @@ -115,6 +122,39 @@ export class AssetRepository implements IAssetRepository { }); } + getByLibraryId(libraryIds: string[]): Promise { + return this.repository.find({ + where: { library: { id: In(libraryIds) } }, + }); + } + + getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise { + return this.repository.findOne({ + where: { library: { id: libraryId }, originalPath: originalPath }, + }); + } + + getById(assetId: string): Promise { + return this.repository.findOneOrFail({ + where: { + id: assetId, + }, + relations: { + exifInfo: true, + tags: true, + sharedLinks: true, + smartInfo: true, + faces: { + person: true, + }, + }, + }); + } + + remove(asset: AssetEntity): Promise { + return this.repository.remove(asset); + } + getAll(pagination: PaginationOptions, options: AssetSearchOptions = {}): Paginated { return paginate(this.repository, pagination, { where: { @@ -273,13 +313,19 @@ export class AssetRepository implements IAssetRepository { }); } - getWith(pagination: PaginationOptions, property: WithProperty): Paginated { + getWith(pagination: PaginationOptions, property: WithProperty, libraryId?: string): Paginated { let where: FindOptionsWhere | FindOptionsWhere[] = {}; switch (property) { case WithProperty.SIDECAR: where = [{ sidecarPath: Not(IsNull()), isVisible: true }]; break; + case WithProperty.IS_OFFLINE: + if (!libraryId) { + throw new Error('Library id is required when finding offline assets'); + } + where = [{ isOffline: true, libraryId: libraryId }]; + break; default: throw new Error(`Invalid getWith property: ${property}`); diff --git a/server/src/infra/repositories/filesystem.provider.spec.ts b/server/src/infra/repositories/filesystem.provider.spec.ts new file mode 100644 index 0000000000..35c12e39af --- /dev/null +++ b/server/src/infra/repositories/filesystem.provider.spec.ts @@ -0,0 +1,209 @@ +import { CrawlOptionsDto } from '@app/domain'; +import mockfs from 'mock-fs'; +import { FilesystemProvider } from './filesystem.provider'; + +describe(FilesystemProvider.name, () => { + const sut: FilesystemProvider = new FilesystemProvider(); + + describe('crawl', () => { + it('should return empty wnen crawling an empty path list', async () => { + const options = new CrawlOptionsDto(); + options.pathsToCrawl = []; + const paths: string[] = await sut.crawl(options); + expect(paths).toHaveLength(0); + }); + + it('should crawl a single path', async () => { + mockfs({ + '/photos/image.jpg': '', + }); + + const options = new CrawlOptionsDto(); + options.pathsToCrawl = ['/photos/']; + const paths: string[] = await sut.crawl(options); + expect(paths.sort()).toEqual(['/photos/image.jpg'].sort()); + }); + + it('should exclude by file extension', async () => { + mockfs({ + '/photos/image.jpg': '', + '/photos/image.tif': '', + }); + + const options = new CrawlOptionsDto(); + options.pathsToCrawl = ['/photos/']; + options.exclusionPatterns = ['**/*.tif']; + const paths: string[] = await sut.crawl(options); + expect(paths.sort()).toEqual(['/photos/image.jpg'].sort()); + }); + + it('should exclude by file extension without case sensitivity', async () => { + mockfs({ + '/photos/image.jpg': '', + '/photos/image.tif': '', + }); + + const options = new CrawlOptionsDto(); + options.pathsToCrawl = ['/photos/']; + options.exclusionPatterns = ['**/*.TIF']; + const paths: string[] = await sut.crawl(options); + expect(paths.sort()).toEqual(['/photos/image.jpg'].sort()); + }); + + it('should exclude by folder', async () => { + mockfs({ + '/photos/image.jpg': '', + '/photos/raw/image.jpg': '', + '/photos/raw2/image.jpg': '', + '/photos/folder/raw/image.jpg': '', + '/photos/crawl/image.jpg': '', + }); + + const options = new CrawlOptionsDto(); + options.pathsToCrawl = ['/photos/']; + options.exclusionPatterns = ['**/raw/**']; + const paths: string[] = await sut.crawl(options); + expect(paths.sort()).toEqual(['/photos/image.jpg', '/photos/raw2/image.jpg', '/photos/crawl/image.jpg'].sort()); + }); + + it('should crawl multiple paths', async () => { + mockfs({ + '/photos/image1.jpg': '', + '/images/image2.jpg': '', + '/albums/image3.jpg': '', + }); + const options = new CrawlOptionsDto(); + options.pathsToCrawl = ['/photos/', '/images/', '/albums/']; + const paths: string[] = await sut.crawl(options); + expect(paths.sort()).toEqual(['/photos/image1.jpg', '/images/image2.jpg', '/albums/image3.jpg'].sort()); + }); + + it('should support globbing paths', async () => { + mockfs({ + '/photos1/image1.jpg': '', + '/photos2/image2.jpg': '', + '/images/image3.jpg': '', + }); + const options = new CrawlOptionsDto(); + options.pathsToCrawl = ['/photos*']; + const paths: string[] = await sut.crawl(options); + expect(paths.sort()).toEqual(['/photos1/image1.jpg', '/photos2/image2.jpg'].sort()); + }); + + it('should crawl a single path without trailing slash', async () => { + mockfs({ + '/photos/image.jpg': '', + }); + const options = new CrawlOptionsDto(); + options.pathsToCrawl = ['/photos']; + const paths: string[] = await sut.crawl(options); + expect(paths.sort()).toEqual(['/photos/image.jpg'].sort()); + }); + + // TODO: test for hidden paths (not yet implemented) + + it('should crawl a single path', async () => { + mockfs({ + '/photos/image.jpg': '', + '/photos/subfolder/image1.jpg': '', + '/photos/subfolder/image2.jpg': '', + '/image1.jpg': '', + }); + const options = new CrawlOptionsDto(); + options.pathsToCrawl = ['/photos/']; + const paths: string[] = await sut.crawl(options); + expect(paths.sort()).toEqual( + ['/photos/image.jpg', '/photos/subfolder/image1.jpg', '/photos/subfolder/image2.jpg'].sort(), + ); + }); + + it('should filter file extensions', async () => { + mockfs({ + '/photos/image.jpg': '', + '/photos/image.txt': '', + '/photos/1': '', + }); + const options = new CrawlOptionsDto(); + options.pathsToCrawl = ['/photos/']; + const paths: string[] = await sut.crawl(options); + expect(paths.sort()).toEqual(['/photos/image.jpg'].sort()); + }); + + it('should include photo and video extensions', async () => { + mockfs({ + '/photos/image.jpg': '', + '/photos/image.jpeg': '', + '/photos/image.heic': '', + '/photos/image.heif': '', + '/photos/image.png': '', + '/photos/image.gif': '', + '/photos/image.tif': '', + '/photos/image.tiff': '', + '/photos/image.webp': '', + '/photos/image.dng': '', + '/photos/image.nef': '', + '/videos/video.mp4': '', + '/videos/video.mov': '', + '/videos/video.webm': '', + }); + + const options = new CrawlOptionsDto(); + options.pathsToCrawl = ['/photos/', '/videos/']; + const paths: string[] = await sut.crawl(options); + + expect(paths.sort()).toEqual( + [ + '/photos/image.jpg', + '/photos/image.jpeg', + '/photos/image.heic', + '/photos/image.heif', + '/photos/image.png', + '/photos/image.gif', + '/photos/image.tif', + '/photos/image.tiff', + '/photos/image.webp', + '/photos/image.dng', + '/photos/image.nef', + '/videos/video.mp4', + '/videos/video.mov', + '/videos/video.webm', + ].sort(), + ); + }); + + it('should check file extensions without case sensitivity', async () => { + mockfs({ + '/photos/image.jpg': '', + '/photos/image.Jpg': '', + '/photos/image.jpG': '', + '/photos/image.JPG': '', + '/photos/image.jpEg': '', + '/photos/image.TIFF': '', + '/photos/image.tif': '', + '/photos/image.dng': '', + '/photos/image.NEF': '', + }); + + const options = new CrawlOptionsDto(); + options.pathsToCrawl = ['/photos/']; + const paths: string[] = await sut.crawl(options); + expect(paths.sort()).toEqual( + [ + '/photos/image.jpg', + '/photos/image.Jpg', + '/photos/image.jpG', + '/photos/image.JPG', + '/photos/image.jpEg', + '/photos/image.TIFF', + '/photos/image.tif', + '/photos/image.dng', + '/photos/image.NEF', + ].sort(), + ); + }); + + afterEach(() => { + mockfs.restore(); + }); + }); +}); diff --git a/server/src/infra/repositories/filesystem.provider.ts b/server/src/infra/repositories/filesystem.provider.ts index 8f7ba3438e..9dcb17425c 100644 --- a/server/src/infra/repositories/filesystem.provider.ts +++ b/server/src/infra/repositories/filesystem.provider.ts @@ -1,7 +1,15 @@ -import { DiskUsage, ImmichReadStream, ImmichZipStream, IStorageRepository } from '@app/domain'; +import { + CrawlOptionsDto, + DiskUsage, + ImmichReadStream, + ImmichZipStream, + IStorageRepository, + mimeTypes, +} from '@app/domain'; import archiver from 'archiver'; import { constants, createReadStream, existsSync, mkdirSync } from 'fs'; import fs, { readdir } from 'fs/promises'; +import { glob } from 'glob'; import mv from 'mv'; import { promisify } from 'node:util'; import path from 'path'; @@ -52,6 +60,8 @@ export class FilesystemProvider implements IStorageRepository { await fs.unlink(file); } + stat = fs.stat; + async unlinkDir(folder: string, options: { recursive?: boolean; force?: boolean }) { await fs.rm(folder, options); } @@ -93,5 +103,25 @@ export class FilesystemProvider implements IStorageRepository { }; } + async crawl(crawlOptions: CrawlOptionsDto): Promise { + const pathsToCrawl = crawlOptions.pathsToCrawl; + + let paths: string; + if (!pathsToCrawl) { + // No paths to crawl, return empty list + return []; + } else if (pathsToCrawl.length === 1) { + paths = pathsToCrawl[0]; + } else { + paths = '{' + pathsToCrawl.join(',') + '}'; + } + + paths = paths + '/**/*{' + mimeTypes.getSupportedFileExtensions().join(',') + '}'; + + return (await glob(paths, { nocase: true, nodir: true, ignore: crawlOptions.exclusionPatterns })).map((assetPath) => + path.normalize(assetPath), + ); + } + readdir = readdir; } diff --git a/server/src/infra/repositories/index.ts b/server/src/infra/repositories/index.ts index c52c350fbc..f1bb6b59f5 100644 --- a/server/src/infra/repositories/index.ts +++ b/server/src/infra/repositories/index.ts @@ -9,6 +9,7 @@ export * from './face.repository'; export * from './filesystem.provider'; export * from './geocoding.repository'; export * from './job.repository'; +export * from './library.repository'; export * from './machine-learning.repository'; export * from './media.repository'; export * from './partner.repository'; diff --git a/server/src/infra/repositories/job.repository.ts b/server/src/infra/repositories/job.repository.ts index 1d24a917e1..0dee3d80eb 100644 --- a/server/src/infra/repositories/job.repository.ts +++ b/server/src/infra/repositories/job.repository.ts @@ -78,7 +78,7 @@ export class JobRepository implements IJobRepository { } } - private getQueue(queue: QueueName) { + private getQueue(queue: QueueName): Queue { return this.moduleRef.get(getQueueToken(queue), { strict: false }); } } diff --git a/server/src/infra/repositories/library.repository.ts b/server/src/infra/repositories/library.repository.ts new file mode 100644 index 0000000000..2b42b3cb51 --- /dev/null +++ b/server/src/infra/repositories/library.repository.ts @@ -0,0 +1,183 @@ +import { ILibraryRepository, LibraryStatsResponseDto } from '@app/domain'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { IsNull, Not } from 'typeorm'; +import { Repository } from 'typeorm/repository/Repository'; +import { LibraryEntity, LibraryType } from '../entities'; + +@Injectable() +export class LibraryRepository implements ILibraryRepository { + constructor(@InjectRepository(LibraryEntity) private repository: Repository) {} + + get(id: string, withDeleted = false): Promise { + return this.repository.findOneOrFail({ + where: { + id, + }, + relations: { owner: true }, + withDeleted, + }); + } + + existsByName(name: string, withDeleted = false): Promise { + return this.repository.exist({ + where: { + name, + }, + withDeleted, + }); + } + + getCountForUser(ownerId: string): Promise { + return this.repository.countBy({ ownerId }); + } + + getDefaultUploadLibrary(ownerId: string): Promise { + return this.repository.findOne({ + where: { + ownerId: ownerId, + type: LibraryType.UPLOAD, + }, + order: { + createdAt: 'ASC', + }, + }); + } + + getUploadLibraryCount(ownerId: string): Promise { + return this.repository.count({ + where: { + ownerId: ownerId, + type: LibraryType.UPLOAD, + }, + }); + } + + getAllByUserId(ownerId: string, type?: LibraryType): Promise { + return this.repository.find({ + where: { + ownerId, + isVisible: true, + type, + }, + relations: { + owner: true, + }, + order: { + createdAt: 'ASC', + }, + }); + } + + getAll(withDeleted = false, type?: LibraryType): Promise { + return this.repository.find({ + where: { type }, + relations: { + owner: true, + }, + order: { + createdAt: 'ASC', + }, + withDeleted, + }); + } + + getAllDeleted(): Promise { + return this.repository.find({ + where: { + isVisible: true, + deletedAt: Not(IsNull()), + }, + relations: { + owner: true, + }, + order: { + createdAt: 'ASC', + }, + withDeleted: true, + }); + } + + create(library: Omit): Promise { + return this.repository.save(library); + } + + async delete(id: string): Promise { + await this.repository.delete({ id }); + } + + async softDelete(id: string): Promise { + await this.repository.softDelete({ id }); + } + + async update(library: Partial): Promise { + return this.save(library); + } + + async getStatistics(id: string): Promise { + const stats = await this.repository + .createQueryBuilder('libraries') + .addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'IMAGE' AND assets.isVisible)`, 'photos') + .addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'VIDEO' AND assets.isVisible)`, 'videos') + .addSelect('COALESCE(SUM(exif.fileSizeInByte), 0)', 'usage') + .leftJoin('libraries.assets', 'assets') + .leftJoin('assets.exifInfo', 'exif') + .groupBy('libraries.id') + .where('libraries.id = :id', { id }) + .getRawOne(); + + return { + photos: Number(stats.photos), + videos: Number(stats.videos), + usage: Number(stats.usage), + total: Number(stats.photos) + Number(stats.videos), + }; + } + + async getOnlineAssetPaths(libraryId: string): Promise { + // Return all non-offline asset paths for a given library + const rawResults = await this.repository + .createQueryBuilder('library') + .innerJoinAndSelect('library.assets', 'assets') + .where('library.id = :id', { id: libraryId }) + .andWhere('assets.isOffline = false') + .select('assets.originalPath') + .getRawMany(); + + const results: string[] = []; + + for (const rawPath of rawResults) { + results.push(rawPath.assets_originalPath); + } + + return results; + } + + async getAssetIds(libraryId: string, withDeleted = false): Promise { + let query = await this.repository + .createQueryBuilder('library') + .innerJoinAndSelect('library.assets', 'assets') + .where('library.id = :id', { id: libraryId }) + .select('assets.id'); + + if (withDeleted) { + query = query.withDeleted(); + } + + // Return all asset paths for a given library + const rawResults = await query.getRawMany(); + + const results: string[] = []; + + for (const rawPath of rawResults) { + results.push(rawPath.assets_id); + } + + return results; + } + + private async save(library: Partial) { + const { id } = await this.repository.save(library); + return this.repository.findOneByOrFail({ id }); + } +} diff --git a/server/src/microservices/app.service.ts b/server/src/microservices/app.service.ts index 08d9ca7d2c..023181f5b5 100644 --- a/server/src/microservices/app.service.ts +++ b/server/src/microservices/app.service.ts @@ -4,6 +4,7 @@ import { IDeleteFilesJob, JobName, JobService, + LibraryService, MediaService, MetadataService, PersonService, @@ -14,6 +15,7 @@ import { SystemConfigService, UserService, } from '@app/domain'; + import { Injectable, Logger } from '@nestjs/common'; import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor'; @@ -37,6 +39,7 @@ export class AppService { private systemConfigService: SystemConfigService, private userService: UserService, private auditService: AuditService, + private libraryService: LibraryService, ) {} async init() { @@ -77,6 +80,13 @@ export class AppService { [JobName.QUEUE_SIDECAR]: (data) => this.metadataService.handleQueueSidecar(data), [JobName.SIDECAR_DISCOVERY]: (data) => this.metadataService.handleSidecarDiscovery(data), [JobName.SIDECAR_SYNC]: () => this.metadataService.handleSidecarSync(), + [JobName.LIBRARY_SCAN_ASSET]: (data) => this.libraryService.handleAssetRefresh(data), + [JobName.LIBRARY_MARK_ASSET_OFFLINE]: (data) => this.libraryService.handleOfflineAsset(data), + [JobName.LIBRARY_SCAN]: (data) => this.libraryService.handleQueueAssetRefresh(data), + [JobName.LIBRARY_DELETE]: (data) => this.libraryService.handleDeleteLibrary(data), + [JobName.LIBRARY_REMOVE_OFFLINE]: (data) => this.libraryService.handleOfflineRemoval(data), + [JobName.LIBRARY_QUEUE_SCAN_ALL]: (data) => this.libraryService.handleQueueAllScan(data), + [JobName.LIBRARY_QUEUE_CLEANUP]: () => this.libraryService.handleQueueCleanup(), }); process.on('uncaughtException', (error: Error | any) => { diff --git a/server/src/microservices/processors/metadata-extraction.processor.ts b/server/src/microservices/processors/metadata-extraction.processor.ts index 3d405920fd..b0274cb575 100644 --- a/server/src/microservices/processors/metadata-extraction.processor.ts +++ b/server/src/microservices/processors/metadata-extraction.processor.ts @@ -146,7 +146,6 @@ export class MetadataExtractionProcessor { await this.applyMotionPhotos(asset, tags); await this.applyReverseGeocoding(asset, exifData); - await this.assetRepository.upsertExif(exifData); await this.assetRepository.save({ id: asset.id, diff --git a/server/src/microservices/utils/exif/coordinates.spec.ts b/server/src/microservices/utils/exif/coordinates.spec.ts index 223a7671ec..fd9ffd5d58 100644 --- a/server/src/microservices/utils/exif/coordinates.spec.ts +++ b/server/src/microservices/utils/exif/coordinates.spec.ts @@ -1,4 +1,3 @@ -import { describe, expect, it } from '@jest/globals'; import { parseLatitude, parseLongitude } from './coordinates'; describe('parsing latitude from string input', () => { diff --git a/server/src/microservices/utils/numbers.spec.ts b/server/src/microservices/utils/numbers.spec.ts index 0f26566df2..19aba8f76a 100644 --- a/server/src/microservices/utils/numbers.spec.ts +++ b/server/src/microservices/utils/numbers.spec.ts @@ -1,4 +1,3 @@ -import { describe, expect, it } from '@jest/globals'; import { isDecimalNumber, isNumberInRange, toNumberOrNull } from './numbers'; describe('checks if a number is a decimal number', () => { diff --git a/server/test/api/index.ts b/server/test/api/index.ts index 38881a0113..f04a3a2096 100644 --- a/server/test/api/index.ts +++ b/server/test/api/index.ts @@ -1,12 +1,14 @@ import { albumApi } from './album-api'; import { assetApi } from './asset-api'; import { authApi } from './auth-api'; +import { libraryApi } from './library-api'; import { sharedLinkApi } from './shared-link-api'; import { userApi } from './user-api'; export const api = { authApi, assetApi, + libraryApi, sharedLinkApi, albumApi, userApi, diff --git a/server/test/api/library-api.ts b/server/test/api/library-api.ts new file mode 100644 index 0000000000..4c5a08aa92 --- /dev/null +++ b/server/test/api/library-api.ts @@ -0,0 +1,10 @@ +import { LibraryResponseDto } from '@app/domain'; +import request from 'supertest'; + +export const libraryApi = { + getAll: async (server: any, accessToken: string) => { + const { body, status } = await request(server).get(`/library/`).set('Authorization', `Bearer ${accessToken}`); + expect(status).toBe(200); + return body as LibraryResponseDto[]; + }, +}; diff --git a/server/test/e2e/album.e2e-spec.ts b/server/test/e2e/album.e2e-spec.ts index cff73354b1..7f60d8124a 100644 --- a/server/test/e2e/album.e2e-spec.ts +++ b/server/test/e2e/album.e2e-spec.ts @@ -106,16 +106,16 @@ describe(`${AlbumController.name} (e2e)`, () => { const { status, body } = await request(server) .get('/album?shared=invalid') .set('Authorization', `Bearer ${user1.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest); + expect(status).toEqual(400); + expect(body).toEqual(errorStub.badRequest(['shared must be a boolean value'])); }); it('should reject an invalid assetId param', async () => { const { status, body } = await request(server) .get('/album?assetId=invalid') .set('Authorization', `Bearer ${user1.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest); + expect(status).toEqual(400); + expect(body).toEqual(errorStub.badRequest(['assetId must be a UUID'])); }); it('should not return shared albums with a deleted owner', async () => { @@ -413,7 +413,7 @@ describe(`${AlbumController.name} (e2e)`, () => { .send({ sharedUserIds: [user1.userId] }); expect(status).toBe(400); - expect(body).toEqual({ ...errorStub.badRequest, message: 'Cannot be shared with owner' }); + expect(body).toEqual(errorStub.badRequest('Cannot be shared with owner')); }); it('should not be able to add existing user to shared album', async () => { @@ -428,7 +428,7 @@ describe(`${AlbumController.name} (e2e)`, () => { .send({ sharedUserIds: [user2.userId] }); expect(status).toBe(400); - expect(body).toEqual({ ...errorStub.badRequest, message: 'User already added' }); + expect(body).toEqual(errorStub.badRequest('User already added')); }); }); }); diff --git a/server/test/e2e/asset.e2e-spec.ts b/server/test/e2e/asset.e2e-spec.ts index ed38fc0ae9..24939d27b9 100644 --- a/server/test/e2e/asset.e2e-spec.ts +++ b/server/test/e2e/asset.e2e-spec.ts @@ -45,6 +45,7 @@ let assetCount = 0; const createAsset = ( repository: IAssetRepository, loginResponse: LoginResponseDto, + libraryId: string, createdAt: Date, ): Promise => { const id = assetCount++; @@ -54,6 +55,7 @@ const createAsset = ( originalPath: `/tests/test_${id}`, deviceAssetId: `test_${id}`, deviceId: 'e2e-test', + libraryId, fileCreatedAt: createdAt, fileModifiedAt: new Date(), type: AssetType.IMAGE, @@ -87,15 +89,19 @@ describe(`${AssetController.name} (e2e)`, () => { await api.authApi.adminSignUp(server); const admin = await api.authApi.adminLogin(server); + const libraries = await api.libraryApi.getAll(server, admin.accessToken); + const defaultLibrary = libraries[0]; + await api.userApi.create(server, admin.accessToken, user1Dto); user1 = await api.authApi.login(server, { email: user1Dto.email, password: user1Dto.password }); - asset1 = await createAsset(assetRepository, user1, new Date('1970-01-01')); - asset2 = await createAsset(assetRepository, user1, new Date('1970-01-02')); - asset3 = await createAsset(assetRepository, user1, new Date('1970-02-01')); + + asset1 = await createAsset(assetRepository, user1, defaultLibrary.id, new Date('1970-01-01')); + asset2 = await createAsset(assetRepository, user1, defaultLibrary.id, new Date('1970-01-02')); + asset3 = await createAsset(assetRepository, user1, defaultLibrary.id, new Date('1970-02-01')); await api.userApi.create(server, admin.accessToken, user2Dto); user2 = await api.authApi.login(server, { email: user2Dto.email, password: user2Dto.password }); - asset4 = await createAsset(assetRepository, user2, new Date('1970-01-01')); + asset4 = await createAsset(assetRepository, user2, defaultLibrary.id, new Date('1970-01-01')); }); afterAll(async () => { @@ -139,7 +145,7 @@ describe(`${AssetController.name} (e2e)`, () => { .attach('assetData', randomBytes(32), 'example.jpg') .field(dto); expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest); + expect(body).toEqual(errorStub.badRequest()); }); } @@ -192,7 +198,7 @@ describe(`${AssetController.name} (e2e)`, () => { .put(`/asset/${uuidStub.invalid}`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest); + expect(body).toEqual(errorStub.badRequest(['id must be a UUID'])); }); it('should require access', async () => { diff --git a/server/test/e2e/auth.e2e-spec.ts b/server/test/e2e/auth.e2e-spec.ts index 7791226cd1..bff6b976ed 100644 --- a/server/test/e2e/auth.e2e-spec.ts +++ b/server/test/e2e/auth.e2e-spec.ts @@ -52,18 +52,33 @@ describe(`${AuthController.name} (e2e)`, () => { }); const invalid = [ - { should: 'require an email address', data: { firstName, lastName, password } }, - { should: 'require a password', data: { firstName, lastName, email } }, - { should: 'require a first name ', data: { lastName, email, password } }, - { should: 'require a last name ', data: { firstName, email, password } }, - { should: 'require a valid email', data: { firstName, lastName, email: 'immich', password } }, + { + should: 'require an email address', + data: { firstName, lastName, password }, + }, + { + should: 'require a password', + data: { firstName, lastName, email }, + }, + { + should: 'require a first name ', + data: { lastName, email, password }, + }, + { + should: 'require a last name ', + data: { firstName, email, password }, + }, + { + should: 'require a valid email', + data: { firstName, lastName, email: 'immich', password }, + }, ]; for (const { should, data } of invalid) { it(`should ${should}`, async () => { const { status, body } = await request(server).post('/auth/admin-sign-up').send(data); expect(status).toEqual(400); - expect(body).toEqual(errorStub.badRequest); + expect(body).toEqual(errorStub.badRequest()); }); } @@ -102,7 +117,7 @@ describe(`${AuthController.name} (e2e)`, () => { .post('/auth/admin-sign-up') .send({ ...adminSignupStub, [key]: null }); expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest); + expect(body).toEqual(errorStub.badRequest()); }); } }); @@ -120,7 +135,7 @@ describe(`${AuthController.name} (e2e)`, () => { .post('/auth/login') .send({ ...loginStub.admin, [key]: null }); expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest); + expect(body).toEqual(errorStub.badRequest()); }); } @@ -225,7 +240,7 @@ describe(`${AuthController.name} (e2e)`, () => { .send({ ...changePasswordStub, [key]: null }) .set('Authorization', `Bearer ${accessToken}`); expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest); + expect(body).toEqual(errorStub.badRequest()); }); } diff --git a/server/test/e2e/library.e2e-spec.ts b/server/test/e2e/library.e2e-spec.ts new file mode 100644 index 0000000000..9a047176e0 --- /dev/null +++ b/server/test/e2e/library.e2e-spec.ts @@ -0,0 +1,494 @@ +import { LoginResponseDto } from '@app/domain'; +import { AppModule, LibraryController } from '@app/immich'; +import { LibraryType } from '@app/infra/entities'; +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import request from 'supertest'; +import { errorStub, userStub, uuidStub } from '../fixtures'; +import { api, db } from '../test-utils'; + +describe(`${LibraryController.name} (e2e)`, () => { + let app: INestApplication; + let server: any; + let loginResponse: LoginResponseDto; + let accessToken: string; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = await moduleFixture.createNestApplication().init(); + server = app.getHttpServer(); + }); + + beforeEach(async () => { + await db.reset(); + await api.adminSignUp(server); + loginResponse = await api.adminLogin(server); + accessToken = loginResponse.accessToken; + }); + + afterAll(async () => { + await db.disconnect(); + await app.close(); + }); + + describe('GET /library', () => { + it('should require authentication', async () => { + const { status, body } = await request(server).get('/library'); + expect(status).toBe(401); + expect(body).toEqual(errorStub.unauthorized); + }); + + it('should start with a default upload library', async () => { + const { status, body } = await request(server).get('/library').set('Authorization', `Bearer ${accessToken}`); + expect(status).toBe(200); + expect(body).toHaveLength(1); + expect(body).toEqual([ + { + id: expect.any(String), + ownerId: loginResponse.userId, + type: LibraryType.UPLOAD, + name: 'Default Library', + createdAt: expect.any(String), + updatedAt: expect.any(String), + refreshedAt: null, + assetCount: 0, + importPaths: [], + exclusionPatterns: [], + }, + ]); + }); + }); + + describe('POST /library', () => { + it('should require authentication', async () => { + const { status, body } = await request(server).post('/library').send({}); + expect(status).toBe(401); + expect(body).toEqual(errorStub.unauthorized); + }); + + describe('external library', () => { + it('with default settings', async () => { + const { status, body } = await request(server) + .post('/library') + .set('Authorization', `Bearer ${accessToken}`) + .send({ type: LibraryType.EXTERNAL }); + expect(status).toBe(201); + + expect(body).toEqual({ + id: expect.any(String), + ownerId: loginResponse.userId, + type: LibraryType.EXTERNAL, + name: 'New External Library', + createdAt: expect.any(String), + updatedAt: expect.any(String), + refreshedAt: null, + assetCount: 0, + importPaths: [], + exclusionPatterns: [], + }); + }); + + it('with name', async () => { + const { status, body } = await request(server) + .post('/library') + .set('Authorization', `Bearer ${accessToken}`) + .send({ type: LibraryType.EXTERNAL, name: 'My Awesome Library' }); + expect(status).toBe(201); + + expect(body).toEqual({ + id: expect.any(String), + ownerId: loginResponse.userId, + type: LibraryType.EXTERNAL, + name: 'My Awesome Library', + createdAt: expect.any(String), + updatedAt: expect.any(String), + refreshedAt: null, + assetCount: 0, + importPaths: [], + exclusionPatterns: [], + }); + }); + + it('with import paths', async () => { + const { status, body } = await request(server) + .post('/library') + .set('Authorization', `Bearer ${accessToken}`) + .send({ type: LibraryType.EXTERNAL, importPaths: ['/path/to/import'] }); + expect(status).toBe(201); + + expect(body).toEqual({ + id: expect.any(String), + ownerId: loginResponse.userId, + type: LibraryType.EXTERNAL, + name: 'New External Library', + createdAt: expect.any(String), + updatedAt: expect.any(String), + refreshedAt: null, + assetCount: 0, + importPaths: ['/path/to/import'], + exclusionPatterns: [], + }); + }); + + it('with exclusion patterns', async () => { + const { status, body } = await request(server) + .post('/library') + .set('Authorization', `Bearer ${accessToken}`) + .send({ type: LibraryType.EXTERNAL, exclusionPatterns: ['**/Raw/**'] }); + expect(status).toBe(201); + + expect(body).toEqual({ + id: expect.any(String), + ownerId: loginResponse.userId, + type: LibraryType.EXTERNAL, + name: 'New External Library', + createdAt: expect.any(String), + updatedAt: expect.any(String), + refreshedAt: null, + assetCount: 0, + importPaths: [], + exclusionPatterns: ['**/Raw/**'], + }); + }); + }); + + describe('upload library', () => { + it('with default settings', async () => { + const { status, body } = await request(server) + .post('/library') + .set('Authorization', `Bearer ${accessToken}`) + .send({ type: LibraryType.UPLOAD }); + expect(status).toBe(201); + + expect(body).toEqual({ + id: expect.any(String), + ownerId: loginResponse.userId, + type: LibraryType.UPLOAD, + name: 'New Upload Library', + createdAt: expect.any(String), + updatedAt: expect.any(String), + refreshedAt: null, + assetCount: 0, + importPaths: [], + exclusionPatterns: [], + }); + }); + + it('with name', async () => { + const { status, body } = await request(server) + .post('/library') + .set('Authorization', `Bearer ${accessToken}`) + .send({ type: LibraryType.UPLOAD, name: 'My Awesome Library' }); + expect(status).toBe(201); + + expect(body).toEqual({ + id: expect.any(String), + ownerId: loginResponse.userId, + type: LibraryType.UPLOAD, + name: 'My Awesome Library', + createdAt: expect.any(String), + updatedAt: expect.any(String), + refreshedAt: null, + assetCount: 0, + importPaths: [], + exclusionPatterns: [], + }); + }); + + it('with import paths should fail', async () => { + const { status, body } = await request(server) + .post('/library') + .set('Authorization', `Bearer ${accessToken}`) + .send({ type: LibraryType.UPLOAD, importPaths: ['/path/to/import'] }); + expect(status).toBe(400); + + expect(body).toEqual(errorStub.badRequest('Upload libraries cannot have import paths')); + }); + + it('with exclusion patterns should fail', async () => { + const { status, body } = await request(server) + .post('/library') + .set('Authorization', `Bearer ${accessToken}`) + .send({ type: LibraryType.UPLOAD, exclusionPatterns: ['**/Raw/**'] }); + expect(status).toBe(400); + + expect(body).toEqual(errorStub.badRequest('Upload libraries cannot have exclusion patterns')); + }); + }); + + it('should allow a user to create a library', async () => { + await api.userCreate(server, accessToken, userStub.user1); + + const loginResponse = await api.login(server, { + email: userStub.user1.email, + password: userStub.user1.password ?? '', + }); + + const { status, body } = await request(server) + .post('/library') + .set('Authorization', `Bearer ${loginResponse.accessToken}`) + .send({ type: LibraryType.EXTERNAL }); + + expect(status).toBe(201); + expect(body).toEqual({ + id: expect.any(String), + ownerId: loginResponse.userId, + type: LibraryType.EXTERNAL, + name: 'New External Library', + createdAt: expect.any(String), + updatedAt: expect.any(String), + refreshedAt: null, + assetCount: 0, + importPaths: [], + exclusionPatterns: [], + }); + }); + }); + + describe('PUT /library/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(server).put(`/library/${uuidStub.notFound}`).send({}); + expect(status).toBe(401); + expect(body).toEqual(errorStub.unauthorized); + }); + + describe('external library', () => { + let libraryId: string; + + beforeEach(async () => { + // Create an external library with default settings + const { status, body } = await request(server) + .post('/library') + .set('Authorization', `Bearer ${accessToken}`) + .send({ type: LibraryType.EXTERNAL }); + + expect(status).toBe(201); + + libraryId = body.id; + }); + + it('should change the library name', async () => { + const { status, body } = await request(server) + .put(`/library/${libraryId}`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ name: 'New Library Name' }); + expect(status).toBe(200); + expect(body).toEqual({ + id: expect.any(String), + ownerId: loginResponse.userId, + type: LibraryType.EXTERNAL, + name: 'New Library Name', + createdAt: expect.any(String), + updatedAt: expect.any(String), + refreshedAt: null, + assetCount: 0, + importPaths: [], + exclusionPatterns: [], + }); + }); + + it('should not set an empty name', async () => { + const { status, body } = await request(server) + .put(`/library/${libraryId}`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ name: '' }); + expect(status).toBe(400); + expect(body).toEqual(errorStub.badRequest(['name should not be empty'])); + }); + + it('should change the import paths', async () => { + const { status, body } = await request(server) + .put(`/library/${libraryId}`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ importPaths: ['/path/to/import'] }); + expect(status).toBe(200); + expect(body).toEqual({ + id: expect.any(String), + ownerId: loginResponse.userId, + type: LibraryType.EXTERNAL, + name: 'New External Library', + createdAt: expect.any(String), + updatedAt: expect.any(String), + refreshedAt: null, + assetCount: 0, + importPaths: ['/path/to/import'], + exclusionPatterns: [], + }); + }); + + it('should not allow an empty import path', async () => { + const { status, body } = await request(server) + .put(`/library/${libraryId}`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ importPaths: [''] }); + expect(status).toBe(400); + expect(body).toEqual(errorStub.badRequest(['each value in importPaths should not be empty'])); + }); + + it('should change the exclusion pattern', async () => { + const { status, body } = await request(server) + .put(`/library/${libraryId}`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ exclusionPatterns: [''] }); + expect(status).toBe(400); + expect(body).toEqual(errorStub.badRequest(['each value in exclusionPatterns should not be empty'])); + }); + + it('should not allow an empty exclusion pattern', async () => { + const { status, body } = await request(server) + .put(`/library/${libraryId}`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ importPaths: [''] }); + expect(status).toBe(400); + expect(body).toEqual(errorStub.badRequest(['each value in importPaths should not be empty'])); + }); + }); + }); + + describe('GET /library/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(server).get(`/library/${uuidStub.notFound}`); + expect(status).toBe(401); + expect(body).toEqual(errorStub.unauthorized); + }); + + it('should get library by id', async () => { + let libraryId: string; + { + const { status, body } = await request(server) + .post('/library') + .set('Authorization', `Bearer ${accessToken}`) + .send({ type: LibraryType.EXTERNAL }); + expect(status).toBe(201); + libraryId = body.id; + } + const { status, body } = await request(server) + .get(`/library/${libraryId}`) + .set('Authorization', `Bearer ${accessToken}`); + + expect(status).toBe(200); + expect(body).toEqual({ + id: expect.any(String), + ownerId: loginResponse.userId, + type: LibraryType.EXTERNAL, + name: 'New External Library', + createdAt: expect.any(String), + updatedAt: expect.any(String), + refreshedAt: null, + assetCount: 0, + importPaths: [], + exclusionPatterns: [], + }); + }); + + it("should not allow getting another user's library", async () => { + await api.userCreate(server, accessToken, userStub.user1); + + const loginResponse = await api.login(server, { + email: userStub.user1.email, + password: userStub.user1.password ?? '', + }); + + let libraryId: string; + { + const { status, body } = await request(server) + .post('/library') + .set('Authorization', `Bearer ${accessToken}`) + .send({ type: LibraryType.EXTERNAL }); + expect(status).toBe(201); + libraryId = body.id; + } + + const { status, body } = await request(server) + .get(`/library/${libraryId}`) + .set('Authorization', `Bearer ${loginResponse.accessToken}`); + + expect(status).toBe(400); + expect(body).toEqual(errorStub.badRequest('Not found or no library.read access')); + }); + }); + + describe('DELETE /library/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(server).delete(`/library/${uuidStub.notFound}`); + expect(status).toBe(401); + expect(body).toEqual(errorStub.unauthorized); + }); + + it('should not delete the last upload library', async () => { + const [defaultLibrary] = await api.libraryApi.getAll(server, accessToken); + expect(defaultLibrary).toBeDefined(); + + const { status, body } = await request(server) + .delete(`/library/${defaultLibrary.id}`) + .set('Authorization', `Bearer ${accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorStub.noDeleteUploadLibrary); + }); + }); + + describe('GET /library/:id/statistics', () => { + it('should require authentication', async () => { + const { status, body } = await request(server).get(`/library/${uuidStub.notFound}/statistics`); + expect(status).toBe(401); + expect(body).toEqual(errorStub.unauthorized); + }); + }); + + describe('POST /library/:id/scan', () => { + it('should require authentication', async () => { + const { status, body } = await request(server).post(`/library/${uuidStub.notFound}/scan`).send({}); + expect(status).toBe(401); + expect(body).toEqual(errorStub.unauthorized); + }); + + it('should scan external library', async () => { + let libraryId: string; + { + const { status, body } = await request(server) + .post('/library') + .set('Authorization', `Bearer ${accessToken}`) + .send({ type: LibraryType.EXTERNAL }); + expect(status).toBe(201); + libraryId = body.id; + } + + const { status, body } = await request(server) + .post(`/library/${libraryId}/scan`) + .set('Authorization', `Bearer ${accessToken}`); + + expect(status).toBe(201); + expect(body).toEqual({}); + }); + + it('should not scan an upload library', async () => { + let libraryId: string; + { + const { status, body } = await request(server) + .post('/library') + .set('Authorization', `Bearer ${accessToken}`) + .send({ type: LibraryType.UPLOAD }); + expect(status).toBe(201); + libraryId = body.id; + } + + const { status, body } = await request(server) + .post(`/library/${libraryId}/scan`) + .set('Authorization', `Bearer ${accessToken}`); + + expect(status).toBe(400); + expect(body).toEqual(errorStub.badRequest('Can only refresh external libraries')); + }); + }); + + describe('POST /library/:id/removeOffline', () => { + it('should require authentication', async () => { + const { status, body } = await request(server).post(`/library/${uuidStub.notFound}/removeOffline`).send({}); + expect(status).toBe(401); + expect(body).toEqual(errorStub.unauthorized); + }); + }); +}); diff --git a/server/test/e2e/oauth.e2e-spec.ts b/server/test/e2e/oauth.e2e-spec.ts index 06717d855f..c2737f2a76 100644 --- a/server/test/e2e/oauth.e2e-spec.ts +++ b/server/test/e2e/oauth.e2e-spec.ts @@ -37,7 +37,7 @@ describe(`${OAuthController.name} (e2e)`, () => { it(`should throw an error if a redirect uri is not provided`, async () => { const { status, body } = await request(server).post('/oauth/authorize').send({}); expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest); + expect(body).toEqual(errorStub.badRequest(['redirectUri must be a string', 'redirectUri should not be empty'])); }); }); }); diff --git a/server/test/e2e/person.e2e-spec.ts b/server/test/e2e/person.e2e-spec.ts index ae95c6391c..8a4ec256f1 100644 --- a/server/test/e2e/person.e2e-spec.ts +++ b/server/test/e2e/person.e2e-spec.ts @@ -110,7 +110,7 @@ describe(`${PersonController.name}`, () => { .set('Authorization', `Bearer ${accessToken}`); expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest); + expect(body).toEqual(errorStub.badRequest()); }); it('should return person information', async () => { @@ -130,25 +130,34 @@ describe(`${PersonController.name}`, () => { expect(body).toEqual(errorStub.unauthorized); }); - for (const key of ['name', 'featureFaceAssetId', 'isHidden']) { + for (const { key, type } of [ + { key: 'name', type: 'string' }, + { key: 'featureFaceAssetId', type: 'string' }, + { key: 'isHidden', type: 'boolean value' }, + ]) { it(`should not allow null ${key}`, async () => { const { status, body } = await request(server) .put(`/person/${visiblePerson.id}`) .set('Authorization', `Bearer ${accessToken}`) .send({ [key]: null }); expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest); + expect(body).toEqual(errorStub.badRequest([`${key} must be a ${type}`])); }); } it('should not accept invalid birth dates', async () => { - for (const birthDate of [false, 'false', '123567', 123456]) { + for (const { birthDate, response } of [ + { birthDate: false, response: ['id must be a UUID'] }, + { birthDate: 'false', response: ['birthDate must be a Date instance'] }, + { birthDate: '123567', response: ['id must be a UUID'] }, + { birthDate: 123456, response: ['id must be a UUID'] }, + ]) { const { status, body } = await request(server) .put(`/person/${uuidStub.notFound}`) .set('Authorization', `Bearer ${accessToken}`) .send({ birthDate }); expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest); + expect(body).toEqual(errorStub.badRequest(response)); } }); diff --git a/server/test/e2e/shared-link.e2e-spec.ts b/server/test/e2e/shared-link.e2e-spec.ts index d47db73a16..7d4c2639fb 100644 --- a/server/test/e2e/shared-link.e2e-spec.ts +++ b/server/test/e2e/shared-link.e2e-spec.ts @@ -160,7 +160,7 @@ describe(`${PartnerController.name} (e2e)`, () => { .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest); + expect(body).toEqual(errorStub.badRequest()); }); it('should require an asset/album id', async () => { @@ -211,7 +211,7 @@ describe(`${PartnerController.name} (e2e)`, () => { .send({ description: 'foo' }); expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest); + expect(body).toEqual(errorStub.badRequest()); }); it('should update shared link', async () => { @@ -241,7 +241,7 @@ describe(`${PartnerController.name} (e2e)`, () => { .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest); + expect(body).toEqual(errorStub.badRequest()); }); it('should update shared link', async () => { diff --git a/server/test/e2e/user.e2e-spec.ts b/server/test/e2e/user.e2e-spec.ts index 550bfbfe86..651aed9a70 100644 --- a/server/test/e2e/user.e2e-spec.ts +++ b/server/test/e2e/user.e2e-spec.ts @@ -138,7 +138,7 @@ describe(`${UserController.name}`, () => { .set('Authorization', `Bearer ${accessToken}`) .send({ ...userSignupStub, [key]: null }); expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest); + expect(body).toEqual(errorStub.badRequest()); }); } @@ -238,7 +238,7 @@ describe(`${UserController.name}`, () => { .set('Authorization', `Bearer ${accessToken}`) .send({ ...userStub.admin, [key]: null }); expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest); + expect(body).toEqual(errorStub.badRequest()); }); } diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index fa7df9f4ad..e5e069fe1a 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -1,6 +1,7 @@ import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities'; import { authStub } from './auth.stub'; import { fileStub } from './file.stub'; +import { libraryStub } from './library.stub'; import { userStub } from './user.stub'; export const assetStub = { @@ -33,6 +34,10 @@ export const assetStub = { faces: [], sidecarPath: null, isReadOnly: false, + isOffline: false, + isExternal: false, + libraryId: 'library-id', + library: libraryStub.uploadLibrary1, }), noWebpPath: Object.freeze({ id: 'asset-id', @@ -63,6 +68,10 @@ export const assetStub = { faces: [], sidecarPath: null, isReadOnly: false, + isOffline: false, + isExternal: false, + libraryId: 'library-id', + library: libraryStub.uploadLibrary1, exifInfo: { fileSizeInByte: 123_000, } as ExifEntity, @@ -87,8 +96,12 @@ export const assetStub = { isFavorite: true, isArchived: false, isReadOnly: false, + isOffline: false, + libraryId: 'library-id', + library: libraryStub.uploadLibrary1, duration: null, isVisible: true, + isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, tags: [], @@ -119,8 +132,86 @@ export const assetStub = { isReadOnly: false, duration: null, isVisible: true, + isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, + isOffline: false, + libraryId: 'library-id', + library: libraryStub.uploadLibrary1, + tags: [], + sharedLinks: [], + originalFileName: 'asset-id.jpg', + faces: [], + sidecarPath: null, + exifInfo: { + fileSizeInByte: 5_000, + } as ExifEntity, + }), + external: Object.freeze({ + id: 'asset-id', + deviceAssetId: 'device-asset-id', + fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), + fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), + owner: userStub.user1, + ownerId: 'user-id', + deviceId: 'device-id', + originalPath: '/data/user1/photo.jpg', + resizePath: '/uploads/user-id/thumbs/path.jpg', + checksum: Buffer.from('file hash', 'utf8'), + type: AssetType.IMAGE, + webpPath: '/uploads/user-id/webp/path.ext', + thumbhash: Buffer.from('blablabla', 'base64'), + encodedVideoPath: null, + createdAt: new Date('2023-02-23T05:06:29.716Z'), + updatedAt: new Date('2023-02-23T05:06:29.716Z'), + isFavorite: true, + isArchived: false, + isReadOnly: false, + isExternal: true, + duration: null, + isVisible: true, + livePhotoVideo: null, + livePhotoVideoId: null, + isOffline: false, + libraryId: 'library-id', + library: libraryStub.uploadLibrary1, + tags: [], + sharedLinks: [], + originalFileName: 'asset-id.jpg', + faces: [], + sidecarPath: null, + exifInfo: { + fileSizeInByte: 5_000, + } as ExifEntity, + }), + offline: Object.freeze({ + id: 'asset-id', + deviceAssetId: 'device-asset-id', + fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), + fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), + owner: userStub.user1, + ownerId: 'user-id', + deviceId: 'device-id', + originalPath: '/original/path.jpg', + resizePath: '/uploads/user-id/thumbs/path.jpg', + checksum: Buffer.from('file hash', 'utf8'), + type: AssetType.IMAGE, + webpPath: '/uploads/user-id/webp/path.ext', + thumbhash: Buffer.from('blablabla', 'base64'), + encodedVideoPath: null, + createdAt: new Date('2023-02-23T05:06:29.716Z'), + updatedAt: new Date('2023-02-23T05:06:29.716Z'), + isFavorite: true, + isArchived: false, + isReadOnly: false, + isExternal: false, + duration: null, + isVisible: true, + livePhotoVideo: null, + livePhotoVideoId: null, + isOffline: true, + libraryId: 'library-id', + library: libraryStub.uploadLibrary1, tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', @@ -149,7 +240,11 @@ export const assetStub = { updatedAt: new Date('2023-02-23T05:06:29.716Z'), isFavorite: true, isArchived: false, + isExternal: false, isReadOnly: false, + isOffline: false, + libraryId: 'library-id', + library: libraryStub.uploadLibrary1, duration: null, isVisible: true, livePhotoVideo: null, @@ -184,6 +279,10 @@ export const assetStub = { isFavorite: true, isArchived: false, isReadOnly: false, + isExternal: false, + isOffline: false, + libraryId: 'library-id', + library: libraryStub.uploadLibrary1, duration: null, isVisible: true, livePhotoVideo: null, @@ -204,6 +303,8 @@ export const assetStub = { isVisible: false, fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'), fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), + libraryId: 'library-id', + library: libraryStub.uploadLibrary1, exifInfo: { fileSizeInByte: 100_000, }, @@ -218,6 +319,8 @@ export const assetStub = { isVisible: true, fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'), fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), + libraryId: 'library-id', + library: libraryStub.uploadLibrary1, exifInfo: { fileSizeInByte: 25_000, }, @@ -244,6 +347,10 @@ export const assetStub = { isFavorite: false, isArchived: false, isReadOnly: false, + isExternal: false, + isOffline: false, + libraryId: 'library-id', + library: libraryStub.uploadLibrary1, duration: null, isVisible: true, livePhotoVideo: null, @@ -278,6 +385,10 @@ export const assetStub = { isFavorite: true, isArchived: false, isReadOnly: false, + isExternal: false, + isOffline: false, + libraryId: 'library-id', + library: libraryStub.uploadLibrary1, duration: null, isVisible: true, livePhotoVideo: null, diff --git a/server/test/fixtures/error.stub.ts b/server/test/fixtures/error.stub.ts index 93a9d296da..c37aad316c 100644 --- a/server/test/fixtures/error.stub.ts +++ b/server/test/fixtures/error.stub.ts @@ -24,11 +24,11 @@ export const errorStub = { statusCode: 401, message: 'Invalid share key', }, - badRequest: { + badRequest: (message: any = null) => ({ error: 'Bad Request', statusCode: 400, - message: expect.any(Array), - }, + message: message ?? expect.anything(), + }), noPermission: { error: 'Bad Request', statusCode: 400, @@ -44,4 +44,9 @@ export const errorStub = { statusCode: 400, message: 'The server already has an admin', }, + noDeleteUploadLibrary: { + error: 'Bad Request', + statusCode: 400, + message: 'Cannot delete the last upload library', + }, }; diff --git a/server/test/fixtures/index.ts b/server/test/fixtures/index.ts index 624cc0758e..ab09d83077 100644 --- a/server/test/fixtures/index.ts +++ b/server/test/fixtures/index.ts @@ -7,6 +7,7 @@ export * from './device.stub'; export * from './error.stub'; export * from './face.stub'; export * from './file.stub'; +export * from './library.stub'; export * from './media.stub'; export * from './partner.stub'; export * from './person.stub'; diff --git a/server/test/fixtures/library.stub.ts b/server/test/fixtures/library.stub.ts new file mode 100644 index 0000000000..edcd305214 --- /dev/null +++ b/server/test/fixtures/library.stub.ts @@ -0,0 +1,33 @@ +import { LibraryEntity, LibraryType } from '@app/infra/entities'; +import { userStub } from './user.stub'; + +export const libraryStub = { + uploadLibrary1: Object.freeze({ + id: 'library-id', + name: 'test_library', + assets: [], + owner: userStub.user1, + ownerId: 'user-id', + type: LibraryType.UPLOAD, + importPaths: [], + createdAt: new Date('2022-01-01'), + updatedAt: new Date('2022-01-01'), + refreshedAt: null, + isVisible: true, + exclusionPatterns: [], + }), + externalLibrary1: Object.freeze({ + id: 'library-id', + name: 'test_library', + assets: [], + owner: userStub.externalPath1, + ownerId: 'user-id', + type: LibraryType.EXTERNAL, + importPaths: [], + createdAt: new Date('2023-01-01'), + updatedAt: new Date('2023-01-01'), + refreshedAt: null, + isVisible: true, + exclusionPatterns: [], + }), +}; diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index ce8378308f..ff4df15790 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -2,6 +2,7 @@ import { AlbumResponseDto, AssetResponseDto, ExifResponseDto, mapUser, SharedLin import { AssetType, SharedLinkEntity, SharedLinkType, UserEntity } from '@app/infra/entities'; import { assetStub } from './asset.stub'; import { authStub } from './auth.stub'; +import { libraryStub } from './library.stub'; import { userStub } from './user.stub'; const today = new Date(); @@ -50,6 +51,9 @@ const assetResponse: AssetResponseDto = { resized: false, thumbhash: null, fileModifiedAt: today, + isExternal: false, + isReadOnly: false, + isOffline: false, fileCreatedAt: today, updatedAt: today, isFavorite: false, @@ -64,6 +68,7 @@ const assetResponse: AssetResponseDto = { tags: [], people: [], checksum: 'ZmlsZSBoYXNo', + libraryId: 'library-id', }; const albumResponse: AlbumResponseDto = { @@ -173,7 +178,11 @@ export const sharedLinkStub = { updatedAt: today, isFavorite: false, isArchived: false, + isExternal: false, isReadOnly: false, + isOffline: false, + libraryId: 'library-id', + library: libraryStub.uploadLibrary1, smartInfo: { assetId: 'id_1', tags: [], diff --git a/server/test/fixtures/user.stub.ts b/server/test/fixtures/user.stub.ts index f2a8dcab81..34d1353293 100644 --- a/server/test/fixtures/user.stub.ts +++ b/server/test/fixtures/user.stub.ts @@ -70,4 +70,38 @@ export const userStub = { assets: [], memoriesEnabled: true, }), + externalPath1: Object.freeze({ + ...authStub.user1, + password: 'immich_password', + firstName: 'immich_first_name', + lastName: 'immich_last_name', + storageLabel: 'label-1', + externalPath: '/data/user1', + oauthId: '', + shouldChangePassword: false, + profileImagePath: '', + createdAt: new Date('2021-01-01'), + deletedAt: null, + updatedAt: new Date('2021-01-01'), + tags: [], + assets: [], + memoriesEnabled: true, + }), + externalPath2: Object.freeze({ + ...authStub.user1, + password: 'immich_password', + firstName: 'immich_first_name', + lastName: 'immich_last_name', + storageLabel: 'label-1', + externalPath: '/data/user2', + oauthId: '', + shouldChangePassword: false, + profileImagePath: '', + createdAt: new Date('2021-01-01'), + deletedAt: null, + updatedAt: new Date('2021-01-01'), + tags: [], + assets: [], + memoriesEnabled: true, + }), }; diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts index eaf8371cb1..c9568283eb 100644 --- a/server/test/repositories/access.repository.mock.ts +++ b/server/test/repositories/access.repository.mock.ts @@ -4,6 +4,7 @@ export interface IAccessRepositoryMock { asset: jest.Mocked; album: jest.Mocked; library: jest.Mocked; + timeline: jest.Mocked; person: jest.Mocked; } @@ -23,6 +24,11 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => { }, library: { + hasOwnerAccess: jest.fn(), + hasPartnerAccess: jest.fn(), + }, + + timeline: { hasPartnerAccess: jest.fn(), }, diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index 0a01a8ce94..b3fde02a54 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -2,6 +2,7 @@ import { IAssetRepository } from '@app/domain'; export const newAssetRepositoryMock = (): jest.Mocked => { return { + create: jest.fn(), upsertExif: jest.fn(), getByDate: jest.fn(), getByIds: jest.fn().mockResolvedValue([]), @@ -14,6 +15,9 @@ export const newAssetRepositoryMock = (): jest.Mocked => { getLastUpdatedAssetForAlbumId: jest.fn(), getAll: jest.fn().mockResolvedValue({ items: [], hasNextPage: false }), updateAll: jest.fn(), + getByLibraryId: jest.fn(), + getById: jest.fn(), + getByLibraryIdAndOriginalPath: jest.fn(), deleteAll: jest.fn(), save: jest.fn(), findLivePhotoMatch: jest.fn(), @@ -21,5 +25,6 @@ export const newAssetRepositoryMock = (): jest.Mocked => { getStatistics: jest.fn(), getByTimeBucket: jest.fn(), getTimeBuckets: jest.fn(), + remove: jest.fn(), }; }; diff --git a/server/test/repositories/index.ts b/server/test/repositories/index.ts index 2b2c190262..d385be9ab9 100644 --- a/server/test/repositories/index.ts +++ b/server/test/repositories/index.ts @@ -7,6 +7,7 @@ export * from './communication.repository.mock'; export * from './crypto.repository.mock'; export * from './face.repository.mock'; export * from './job.repository.mock'; +export * from './library.repository.mock'; export * from './machine-learning.repository.mock'; export * from './media.repository.mock'; export * from './partner.repository.mock'; diff --git a/server/test/repositories/library.repository.mock.ts b/server/test/repositories/library.repository.mock.ts new file mode 100644 index 0000000000..264acc08ef --- /dev/null +++ b/server/test/repositories/library.repository.mock.ts @@ -0,0 +1,21 @@ +import { ILibraryRepository } from '@app/domain'; + +export const newLibraryRepositoryMock = (): jest.Mocked => { + return { + get: jest.fn(), + getCountForUser: jest.fn(), + getAllByUserId: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + softDelete: jest.fn(), + update: jest.fn(), + getStatistics: jest.fn(), + getDefaultUploadLibrary: jest.fn(), + getUploadLibraryCount: jest.fn(), + getOnlineAssetPaths: jest.fn(), + getAssetIds: jest.fn(), + existsByName: jest.fn(), + getAllDeleted: jest.fn(), + getAll: jest.fn(), + }; +}; diff --git a/server/test/repositories/storage.repository.mock.ts b/server/test/repositories/storage.repository.mock.ts index 94c95228bf..66ff1ffbc3 100644 --- a/server/test/repositories/storage.repository.mock.ts +++ b/server/test/repositories/storage.repository.mock.ts @@ -12,5 +12,7 @@ export const newStorageRepositoryMock = (): jest.Mocked => { mkdirSync: jest.fn(), checkDiskUsage: jest.fn(), readdir: jest.fn(), + stat: jest.fn(), + crawl: jest.fn(), }; }; diff --git a/server/test/test-utils.ts b/server/test/test-utils.ts new file mode 100644 index 0000000000..6f62ebd5c0 --- /dev/null +++ b/server/test/test-utils.ts @@ -0,0 +1,175 @@ +import { + AdminSignupResponseDto, + AlbumResponseDto, + AuthDeviceResponseDto, + AuthUserDto, + CreateUserDto, + LibraryResponseDto, + LoginCredentialDto, + LoginResponseDto, + SharedLinkCreateDto, + SharedLinkResponseDto, + UpdateUserDto, + UserResponseDto, +} from '@app/domain'; +import { CreateAlbumDto } from '@app/domain/album/dto/album-create.dto'; +import { dataSource } from '@app/infra'; +import { UserEntity } from '@app/infra/entities'; +import request from 'supertest'; +import { adminSignupStub, loginResponseStub, loginStub, signupResponseStub } from './fixtures'; + +export const db = { + reset: async () => { + if (!dataSource.isInitialized) { + await dataSource.initialize(); + } + + await dataSource.transaction(async (em) => { + for (const entity of dataSource.entityMetadatas) { + if (entity.tableName === 'users') { + continue; + } + await em.query(`DELETE FROM ${entity.tableName} CASCADE;`); + } + await em.query(`DELETE FROM "users" CASCADE;`); + }); + }, + disconnect: async () => { + if (dataSource.isInitialized) { + await dataSource.destroy(); + } + }, +}; + +export function getAuthUser(): AuthUserDto { + return { + id: '3108ac14-8afb-4b7e-87fd-39ebb6b79750', + email: 'test@email.com', + isAdmin: false, + }; +} + +export const api = { + adminSignUp: async (server: any) => { + const { status, body } = await request(server).post('/auth/admin-sign-up').send(adminSignupStub); + + expect(status).toBe(201); + expect(body).toEqual(signupResponseStub); + + return body as AdminSignupResponseDto; + }, + adminLogin: async (server: any) => { + const { status, body } = await request(server).post('/auth/login').send(loginStub.admin); + + expect(body).toEqual(loginResponseStub.admin.response); + expect(body).toMatchObject({ accessToken: expect.any(String) }); + expect(status).toBe(201); + + return body as LoginResponseDto; + }, + userCreate: async (server: any, accessToken: string, user: Partial) => { + const { status, body } = await request(server) + .post('/user') + .set('Authorization', `Bearer ${accessToken}`) + .send(user); + + expect(status).toBe(201); + + return body as UserResponseDto; + }, + login: async (server: any, dto: LoginCredentialDto) => { + const { status, body } = await request(server).post('/auth/login').send(dto); + + expect(status).toEqual(201); + expect(body).toMatchObject({ accessToken: expect.any(String) }); + + return body as LoginResponseDto; + }, + getAuthDevices: async (server: any, accessToken: string) => { + const { status, body } = await request(server).get('/auth/devices').set('Authorization', `Bearer ${accessToken}`); + + expect(body).toEqual(expect.any(Array)); + expect(status).toBe(200); + + return body as AuthDeviceResponseDto[]; + }, + validateToken: async (server: any, accessToken: string) => { + const response = await request(server).post('/auth/validateToken').set('Authorization', `Bearer ${accessToken}`); + expect(response.body).toEqual({ authStatus: true }); + expect(response.status).toBe(200); + }, + albumApi: { + create: async (server: any, accessToken: string, dto: CreateAlbumDto) => { + const res = await request(server).post('/album').set('Authorization', `Bearer ${accessToken}`).send(dto); + expect(res.status).toEqual(201); + return res.body as AlbumResponseDto; + }, + }, + libraryApi: { + getAll: async (server: any, accessToken: string) => { + const res = await request(server).get('/library').set('Authorization', `Bearer ${accessToken}`); + expect(res.status).toEqual(200); + expect(Array.isArray(res.body)).toBe(true); + return res.body as LibraryResponseDto[]; + }, + }, + sharedLinkApi: { + create: async (server: any, accessToken: string, dto: SharedLinkCreateDto) => { + const { status, body } = await request(server) + .post('/shared-link') + .set('Authorization', `Bearer ${accessToken}`) + .send(dto); + expect(status).toBe(201); + return body as SharedLinkResponseDto; + }, + }, + userApi: { + create: async (server: any, accessToken: string, dto: CreateUserDto) => { + const { status, body } = await request(server) + .post('/user') + .set('Authorization', `Bearer ${accessToken}`) + .send(dto); + + expect(status).toBe(201); + expect(body).toMatchObject({ + id: expect.any(String), + createdAt: expect.any(String), + updatedAt: expect.any(String), + email: dto.email, + }); + + return body as UserResponseDto; + }, + get: async (server: any, accessToken: string, id: string) => { + const { status, body } = await request(server) + .get(`/user/info/${id}`) + .set('Authorization', `Bearer ${accessToken}`); + + expect(status).toBe(200); + expect(body).toMatchObject({ id }); + + return body as UserResponseDto; + }, + update: async (server: any, accessToken: string, dto: UpdateUserDto) => { + const { status, body } = await request(server) + .put('/user') + .set('Authorization', `Bearer ${accessToken}`) + .send(dto); + + expect(status).toBe(200); + expect(body).toMatchObject({ id: dto.id }); + + return body as UserResponseDto; + }, + delete: async (server: any, accessToken: string, id: string) => { + const { status, body } = await request(server) + .delete(`/user/${id}`) + .set('Authorization', `Bearer ${accessToken}`); + + expect(status).toBe(200); + expect(body).toMatchObject({ id, deletedAt: expect.any(String) }); + + return body as UserResponseDto; + }, + }, +} as const; diff --git a/web/package-lock.json b/web/package-lock.json index 9df735b093..cecddfe9cf 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -21,6 +21,7 @@ "lodash-es": "^4.17.21", "luxon": "^3.2.1", "socket.io-client": "^4.6.1", + "svelte-loading-spinners": "^0.3.4", "svelte-local-storage-store": "^0.5.0", "svelte-material-icons": "^3.0.5", "thumbhash": "^0.1.1" @@ -29,6 +30,7 @@ "@babel/preset-env": "^7.20.2", "@babel/preset-typescript": "^7.22.5", "@faker-js/faker": "^7.6.0", + "@floating-ui/dom": "^1.5.1", "@sveltejs/adapter-node": "^1.2.0", "@sveltejs/kit": "^1.20.4", "@testing-library/jest-dom": "^5.16.5", @@ -48,6 +50,9 @@ "eslint-config-prettier": "^8.6.0", "eslint-plugin-svelte": "^2.30.0", "factory.ts": "^1.3.0", + "flowbite": "^1.8.1", + "flowbite-svelte": "^0.43.1", + "flowbite-svelte-icons": "^0.3.6", "identity-obj-proxy": "^3.0.0", "jest": "^29.4.3", "jest-environment-jsdom": "^29.4.3", @@ -59,6 +64,7 @@ "svelte-check": "^3.4.3", "svelte-jester": "^2.3.2", "svelte-preprocess": "^5.0.3", + "tailwind-merge": "^1.14.0", "tailwindcss": "^3.2.7", "tslib": "^2.5.0", "typescript": "^5.0.0", @@ -2345,6 +2351,31 @@ "npm": ">=6.0.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.4.1.tgz", + "integrity": "sha512-jk3WqquEJRlcyu7997NtR5PibI+y5bi+LS3hPmguVClypenMsCY3CBa3LAQnozRCtCrYWSEtAdiskpamuJRFOQ==", + "dev": true, + "dependencies": { + "@floating-ui/utils": "^0.1.1" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.5.1.tgz", + "integrity": "sha512-KwvVcPSXg6mQygvA1TjbN/gh///36kKtllIF8SUm0qpFj8+rvYrpvlYdL1JoA71SHpDqgSSdGOSoQ0Mp3uY5aw==", + "dev": true, + "dependencies": { + "@floating-ui/core": "^1.4.1", + "@floating-ui/utils": "^0.1.1" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.1.tgz", + "integrity": "sha512-m0G6wlnhm/AX0H12IOWtK8gASEMffnX08RtKkCgTdHb9JpHKGloI7icFfLg9ZmQeavcvR0PKmzxClyuFPSjKWw==", + "dev": true + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.10.tgz", @@ -3173,6 +3204,16 @@ "integrity": "sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==", "dev": true }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "dev": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, "node_modules/@rollup/plugin-commonjs": { "version": "24.0.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-24.0.1.tgz", @@ -6195,6 +6236,45 @@ "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", "dev": true }, + "node_modules/flowbite": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/flowbite/-/flowbite-1.8.1.tgz", + "integrity": "sha512-lXTcO8a6dRTPFpINyOLcATCN/pK1Of/jY4PryklPllAiqH64tSDUsOdQpar3TO59ZXWwugm2e92oaqwH6X90Xg==", + "dev": true, + "dependencies": { + "@popperjs/core": "^2.9.3", + "mini-svg-data-uri": "^1.4.3" + } + }, + "node_modules/flowbite-svelte": { + "version": "0.43.1", + "resolved": "https://registry.npmjs.org/flowbite-svelte/-/flowbite-svelte-0.43.1.tgz", + "integrity": "sha512-01ofjsHi7YRNx/MvmjpULQ5L6ar8El7yqWD3aJJupyaXRvTyPb5CHPUP5fT1rOJA11oeZDnPRTdJ27aDuTXpZQ==", + "dev": true, + "dependencies": { + "@floating-ui/dom": "^1.5.1", + "flowbite": "^1.8.1", + "tailwind-merge": "^1.14.0" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=7.0.0" + }, + "peerDependencies": { + "svelte": "^3.55.1 || ^4.0.0" + } + }, + "node_modules/flowbite-svelte-icons": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/flowbite-svelte-icons/-/flowbite-svelte-icons-0.3.6.tgz", + "integrity": "sha512-4YEq++cbD36KF+zGgLqfkmQgfWGMAP7tjDbesuieROx6UgbMBTtj7f4n49iO+g1cMLelGsCkyZiwelCXDbIJ2w==", + "dev": true, + "peerDependencies": { + "svelte": "^3.54.0 || ^4.0.0", + "tailwind-merge": "^1.13.2", + "tailwindcss": "^3.3.2" + } + }, "node_modules/follow-redirects": { "version": "1.15.2", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", @@ -9549,6 +9629,15 @@ "node": ">=4" } }, + "node_modules/mini-svg-data-uri": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", + "dev": true, + "bin": { + "mini-svg-data-uri": "cli.js" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -11224,6 +11313,11 @@ "svelte": ">= 3" } }, + "node_modules/svelte-loading-spinners": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/svelte-loading-spinners/-/svelte-loading-spinners-0.3.4.tgz", + "integrity": "sha512-vKaW71QMCBcTNijAGc0mUl8k3DQ66iYmp6MB8BMGCXyWk82bTrcLy8FOnSm9fE+8q6TwzD6PLUoYFHt0II93Xw==" + }, "node_modules/svelte-local-storage-store": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/svelte-local-storage-store/-/svelte-local-storage-store-0.5.0.tgz", @@ -11351,6 +11445,16 @@ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true }, + "node_modules/tailwind-merge": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-1.14.0.tgz", + "integrity": "sha512-3mFKyCo/MBcgyOTlrY8T7odzZFx+w+qKSMAmdFzRvqBfLlSigU6TZnlFHK0lkMwj9Bj8OYU+9yW9lmGuS0QEnQ==", + "dev": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.2.tgz", @@ -13657,6 +13761,31 @@ "integrity": "sha512-XK6BTq1NDMo9Xqw/YkYyGjSsg44fbNwYRx7QK2CuoQgyy+f1rrTDHoExVM5PsyXCtfl2vs2vVJ0MN0yN6LppRw==", "dev": true }, + "@floating-ui/core": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.4.1.tgz", + "integrity": "sha512-jk3WqquEJRlcyu7997NtR5PibI+y5bi+LS3hPmguVClypenMsCY3CBa3LAQnozRCtCrYWSEtAdiskpamuJRFOQ==", + "dev": true, + "requires": { + "@floating-ui/utils": "^0.1.1" + } + }, + "@floating-ui/dom": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.5.1.tgz", + "integrity": "sha512-KwvVcPSXg6mQygvA1TjbN/gh///36kKtllIF8SUm0qpFj8+rvYrpvlYdL1JoA71SHpDqgSSdGOSoQ0Mp3uY5aw==", + "dev": true, + "requires": { + "@floating-ui/core": "^1.4.1", + "@floating-ui/utils": "^0.1.1" + } + }, + "@floating-ui/utils": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.1.tgz", + "integrity": "sha512-m0G6wlnhm/AX0H12IOWtK8gASEMffnX08RtKkCgTdHb9JpHKGloI7icFfLg9ZmQeavcvR0PKmzxClyuFPSjKWw==", + "dev": true + }, "@humanwhocodes/config-array": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.10.tgz", @@ -14285,6 +14414,12 @@ "integrity": "sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==", "dev": true }, + "@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "dev": true + }, "@rollup/plugin-commonjs": { "version": "24.0.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-24.0.1.tgz", @@ -16518,6 +16653,34 @@ "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", "dev": true }, + "flowbite": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/flowbite/-/flowbite-1.8.1.tgz", + "integrity": "sha512-lXTcO8a6dRTPFpINyOLcATCN/pK1Of/jY4PryklPllAiqH64tSDUsOdQpar3TO59ZXWwugm2e92oaqwH6X90Xg==", + "dev": true, + "requires": { + "@popperjs/core": "^2.9.3", + "mini-svg-data-uri": "^1.4.3" + } + }, + "flowbite-svelte": { + "version": "0.43.1", + "resolved": "https://registry.npmjs.org/flowbite-svelte/-/flowbite-svelte-0.43.1.tgz", + "integrity": "sha512-01ofjsHi7YRNx/MvmjpULQ5L6ar8El7yqWD3aJJupyaXRvTyPb5CHPUP5fT1rOJA11oeZDnPRTdJ27aDuTXpZQ==", + "dev": true, + "requires": { + "@floating-ui/dom": "^1.5.1", + "flowbite": "^1.8.1", + "tailwind-merge": "^1.14.0" + } + }, + "flowbite-svelte-icons": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/flowbite-svelte-icons/-/flowbite-svelte-icons-0.3.6.tgz", + "integrity": "sha512-4YEq++cbD36KF+zGgLqfkmQgfWGMAP7tjDbesuieROx6UgbMBTtj7f4n49iO+g1cMLelGsCkyZiwelCXDbIJ2w==", + "dev": true, + "requires": {} + }, "follow-redirects": { "version": "1.15.2", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", @@ -18987,6 +19150,12 @@ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", "dev": true }, + "mini-svg-data-uri": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", + "dev": true + }, "minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -20160,6 +20329,11 @@ "dev": true, "requires": {} }, + "svelte-loading-spinners": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/svelte-loading-spinners/-/svelte-loading-spinners-0.3.4.tgz", + "integrity": "sha512-vKaW71QMCBcTNijAGc0mUl8k3DQ66iYmp6MB8BMGCXyWk82bTrcLy8FOnSm9fE+8q6TwzD6PLUoYFHt0II93Xw==" + }, "svelte-local-storage-store": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/svelte-local-storage-store/-/svelte-local-storage-store-0.5.0.tgz", @@ -20191,6 +20365,12 @@ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true }, + "tailwind-merge": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-1.14.0.tgz", + "integrity": "sha512-3mFKyCo/MBcgyOTlrY8T7odzZFx+w+qKSMAmdFzRvqBfLlSigU6TZnlFHK0lkMwj9Bj8OYU+9yW9lmGuS0QEnQ==", + "dev": true + }, "tailwindcss": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.2.tgz", diff --git a/web/package.json b/web/package.json index 31eae368dd..d555cb22d2 100644 --- a/web/package.json +++ b/web/package.json @@ -23,6 +23,7 @@ "@babel/preset-env": "^7.20.2", "@babel/preset-typescript": "^7.22.5", "@faker-js/faker": "^7.6.0", + "@floating-ui/dom": "^1.5.1", "@sveltejs/adapter-node": "^1.2.0", "@sveltejs/kit": "^1.20.4", "@testing-library/jest-dom": "^5.16.5", @@ -42,6 +43,9 @@ "eslint-config-prettier": "^8.6.0", "eslint-plugin-svelte": "^2.30.0", "factory.ts": "^1.3.0", + "flowbite": "^1.8.1", + "flowbite-svelte": "^0.43.1", + "flowbite-svelte-icons": "^0.3.6", "identity-obj-proxy": "^3.0.0", "jest": "^29.4.3", "jest-environment-jsdom": "^29.4.3", @@ -53,6 +57,7 @@ "svelte-check": "^3.4.3", "svelte-jester": "^2.3.2", "svelte-preprocess": "^5.0.3", + "tailwind-merge": "^1.14.0", "tailwindcss": "^3.2.7", "tslib": "^2.5.0", "typescript": "^5.0.0", @@ -73,6 +78,7 @@ "lodash-es": "^4.17.21", "luxon": "^3.2.1", "socket.io-client": "^4.6.1", + "svelte-loading-spinners": "^0.3.4", "svelte-local-storage-store": "^0.5.0", "svelte-material-icons": "^3.0.5", "thumbhash": "^0.1.1" diff --git a/web/src/api/api.ts b/web/src/api/api.ts index 866b78ba3a..70fa5d33ab 100644 --- a/web/src/api/api.ts +++ b/web/src/api/api.ts @@ -1,5 +1,6 @@ import { AlbumApi, + LibraryApi, APIKeyApi, AssetApi, AssetApiFp, @@ -25,6 +26,7 @@ import type { ApiParams } from './types'; export class ImmichApi { public albumApi: AlbumApi; + public libraryApi: LibraryApi; public assetApi: AssetApi; public authenticationApi: AuthenticationApi; public jobApi: JobApi; @@ -49,6 +51,7 @@ export class ImmichApi { this.config = new Configuration(params); this.albumApi = new AlbumApi(this.config); + this.libraryApi = new LibraryApi(this.config); this.assetApi = new AssetApi(this.config); this.authenticationApi = new AuthenticationApi(this.config); this.jobApi = new JobApi(this.config); @@ -130,6 +133,7 @@ export class ImmichApi { [JobName.StorageTemplateMigration]: 'Storage Template Migration', [JobName.BackgroundTask]: 'Background Tasks', [JobName.Search]: 'Search', + [JobName.Library]: 'Library', }; return names[jobName]; diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 90e96e3998..0ae5745a79 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -295,6 +295,12 @@ export interface AllJobStatusResponseDto { * @memberof AllJobStatusResponseDto */ 'clipEncoding': JobStatusDto; + /** + * + * @type {JobStatusDto} + * @memberof AllJobStatusResponseDto + */ + 'library': JobStatusDto; /** * * @type {JobStatusDto} @@ -621,12 +627,36 @@ export interface AssetResponseDto { * @memberof AssetResponseDto */ 'isArchived': boolean; + /** + * + * @type {boolean} + * @memberof AssetResponseDto + */ + 'isExternal': boolean; /** * * @type {boolean} * @memberof AssetResponseDto */ 'isFavorite': boolean; + /** + * + * @type {boolean} + * @memberof AssetResponseDto + */ + 'isOffline': boolean; + /** + * + * @type {boolean} + * @memberof AssetResponseDto + */ + 'isReadOnly': boolean; + /** + * + * @type {string} + * @memberof AssetResponseDto + */ + 'libraryId': string; /** * * @type {string} @@ -1097,6 +1127,45 @@ export interface CreateAlbumDto { */ 'sharedWithUserIds'?: Array; } +/** + * + * @export + * @interface CreateLibraryDto + */ +export interface CreateLibraryDto { + /** + * + * @type {Array} + * @memberof CreateLibraryDto + */ + 'exclusionPatterns'?: Array; + /** + * + * @type {Array} + * @memberof CreateLibraryDto + */ + 'importPaths'?: Array; + /** + * + * @type {boolean} + * @memberof CreateLibraryDto + */ + 'isVisible'?: boolean; + /** + * + * @type {string} + * @memberof CreateLibraryDto + */ + 'name'?: string; + /** + * + * @type {LibraryType} + * @memberof CreateLibraryDto + */ + 'type': LibraryType; +} + + /** * * @export @@ -1572,12 +1641,24 @@ export interface ImportAssetDto { * @memberof ImportAssetDto */ 'isArchived'?: boolean; + /** + * + * @type {boolean} + * @memberof ImportAssetDto + */ + 'isExternal'?: boolean; /** * * @type {boolean} * @memberof ImportAssetDto */ 'isFavorite': boolean; + /** + * + * @type {boolean} + * @memberof ImportAssetDto + */ + 'isOffline'?: boolean; /** * * @type {boolean} @@ -1590,6 +1671,12 @@ export interface ImportAssetDto { * @memberof ImportAssetDto */ 'isVisible'?: boolean; + /** + * + * @type {string} + * @memberof ImportAssetDto + */ + 'libraryId'?: string; /** * * @type {string} @@ -1693,7 +1780,8 @@ export const JobName = { BackgroundTask: 'backgroundTask', StorageTemplateMigration: 'storageTemplateMigration', Search: 'search', - Sidecar: 'sidecar' + Sidecar: 'sidecar', + Library: 'library' } as const; export type JobName = typeof JobName[keyof typeof JobName]; @@ -1731,6 +1819,120 @@ export interface JobStatusDto { */ 'queueStatus': QueueStatusDto; } +/** + * + * @export + * @interface LibraryResponseDto + */ +export interface LibraryResponseDto { + /** + * + * @type {number} + * @memberof LibraryResponseDto + */ + 'assetCount': number; + /** + * + * @type {string} + * @memberof LibraryResponseDto + */ + 'createdAt': string; + /** + * + * @type {Array} + * @memberof LibraryResponseDto + */ + 'exclusionPatterns': Array; + /** + * + * @type {string} + * @memberof LibraryResponseDto + */ + 'id': string; + /** + * + * @type {Array} + * @memberof LibraryResponseDto + */ + 'importPaths': Array; + /** + * + * @type {string} + * @memberof LibraryResponseDto + */ + 'name': string; + /** + * + * @type {string} + * @memberof LibraryResponseDto + */ + 'ownerId': string; + /** + * + * @type {string} + * @memberof LibraryResponseDto + */ + 'refreshedAt': string | null; + /** + * + * @type {LibraryType} + * @memberof LibraryResponseDto + */ + 'type': LibraryType; + /** + * + * @type {string} + * @memberof LibraryResponseDto + */ + 'updatedAt': string; +} + + +/** + * + * @export + * @interface LibraryStatsResponseDto + */ +export interface LibraryStatsResponseDto { + /** + * + * @type {number} + * @memberof LibraryStatsResponseDto + */ + 'photos': number; + /** + * + * @type {number} + * @memberof LibraryStatsResponseDto + */ + 'total': number; + /** + * + * @type {number} + * @memberof LibraryStatsResponseDto + */ + 'usage': number; + /** + * + * @type {number} + * @memberof LibraryStatsResponseDto + */ + 'videos': number; +} +/** + * + * @export + * @enum {string} + */ + +export const LibraryType = { + Upload: 'UPLOAD', + External: 'EXTERNAL' +} as const; + +export type LibraryType = typeof LibraryType[keyof typeof LibraryType]; + + /** * * @export @@ -2179,6 +2381,25 @@ export interface RecognitionConfig { } +/** + * + * @export + * @interface ScanLibraryDto + */ +export interface ScanLibraryDto { + /** + * + * @type {boolean} + * @memberof ScanLibraryDto + */ + 'refreshAllFiles'?: boolean; + /** + * + * @type {boolean} + * @memberof ScanLibraryDto + */ + 'refreshModifiedFiles'?: boolean; +} /** * * @export @@ -3007,6 +3228,12 @@ export interface SystemConfigJobDto { * @memberof SystemConfigJobDto */ 'clipEncoding': JobSettingsDto; + /** + * + * @type {JobSettingsDto} + * @memberof SystemConfigJobDto + */ + 'library': JobSettingsDto; /** * * @type {JobSettingsDto} @@ -3486,6 +3713,37 @@ export interface UpdateAssetDto { */ 'isFavorite'?: boolean; } +/** + * + * @export + * @interface UpdateLibraryDto + */ +export interface UpdateLibraryDto { + /** + * + * @type {Array} + * @memberof UpdateLibraryDto + */ + 'exclusionPatterns'?: Array; + /** + * + * @type {Array} + * @memberof UpdateLibraryDto + */ + 'importPaths'?: Array; + /** + * + * @type {boolean} + * @memberof UpdateLibraryDto + */ + 'isVisible'?: boolean; + /** + * + * @type {string} + * @memberof UpdateLibraryDto + */ + 'name'?: string; +} /** * * @export @@ -6463,14 +6721,17 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration * @param {string} [key] * @param {string} [duration] * @param {boolean} [isArchived] + * @param {boolean} [isExternal] + * @param {boolean} [isOffline] * @param {boolean} [isReadOnly] * @param {boolean} [isVisible] + * @param {string} [libraryId] * @param {File} [livePhotoData] * @param {File} [sidecarData] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - uploadFile: async (assetData: File, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, isFavorite: boolean, key?: string, duration?: string, isArchived?: boolean, isReadOnly?: boolean, isVisible?: boolean, livePhotoData?: File, sidecarData?: File, options: AxiosRequestConfig = {}): Promise => { + uploadFile: async (assetData: File, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, isFavorite: boolean, key?: string, duration?: string, isArchived?: boolean, isExternal?: boolean, isOffline?: boolean, isReadOnly?: boolean, isVisible?: boolean, libraryId?: string, livePhotoData?: File, sidecarData?: File, options: AxiosRequestConfig = {}): Promise => { // verify required parameter 'assetData' is not null or undefined assertParamExists('uploadFile', 'assetData', assetData) // verify required parameter 'deviceAssetId' is not null or undefined @@ -6538,10 +6799,18 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration localVarFormParams.append('isArchived', isArchived as any); } + if (isExternal !== undefined) { + localVarFormParams.append('isExternal', isExternal as any); + } + if (isFavorite !== undefined) { localVarFormParams.append('isFavorite', isFavorite as any); } + if (isOffline !== undefined) { + localVarFormParams.append('isOffline', isOffline as any); + } + if (isReadOnly !== undefined) { localVarFormParams.append('isReadOnly', isReadOnly as any); } @@ -6550,6 +6819,10 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration localVarFormParams.append('isVisible', isVisible as any); } + if (libraryId !== undefined) { + localVarFormParams.append('libraryId', libraryId as any); + } + if (livePhotoData !== undefined) { localVarFormParams.append('livePhotoData', livePhotoData as any); } @@ -6871,15 +7144,18 @@ export const AssetApiFp = function(configuration?: Configuration) { * @param {string} [key] * @param {string} [duration] * @param {boolean} [isArchived] + * @param {boolean} [isExternal] + * @param {boolean} [isOffline] * @param {boolean} [isReadOnly] * @param {boolean} [isVisible] + * @param {string} [libraryId] * @param {File} [livePhotoData] * @param {File} [sidecarData] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async uploadFile(assetData: File, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, isFavorite: boolean, key?: string, duration?: string, isArchived?: boolean, isReadOnly?: boolean, isVisible?: boolean, livePhotoData?: File, sidecarData?: File, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, key, duration, isArchived, isReadOnly, isVisible, livePhotoData, sidecarData, options); + async uploadFile(assetData: File, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, isFavorite: boolean, key?: string, duration?: string, isArchived?: boolean, isExternal?: boolean, isOffline?: boolean, isReadOnly?: boolean, isVisible?: boolean, libraryId?: string, livePhotoData?: File, sidecarData?: File, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, key, duration, isArchived, isExternal, isOffline, isReadOnly, isVisible, libraryId, livePhotoData, sidecarData, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, } @@ -7121,7 +7397,7 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath * @throws {RequiredError} */ uploadFile(requestParameters: AssetApiUploadFileRequest, options?: AxiosRequestConfig): AxiosPromise { - return localVarFp.uploadFile(requestParameters.assetData, requestParameters.deviceAssetId, requestParameters.deviceId, requestParameters.fileCreatedAt, requestParameters.fileModifiedAt, requestParameters.isFavorite, requestParameters.key, requestParameters.duration, requestParameters.isArchived, requestParameters.isReadOnly, requestParameters.isVisible, requestParameters.livePhotoData, requestParameters.sidecarData, options).then((request) => request(axios, basePath)); + return localVarFp.uploadFile(requestParameters.assetData, requestParameters.deviceAssetId, requestParameters.deviceId, requestParameters.fileCreatedAt, requestParameters.fileModifiedAt, requestParameters.isFavorite, requestParameters.key, requestParameters.duration, requestParameters.isArchived, requestParameters.isExternal, requestParameters.isOffline, requestParameters.isReadOnly, requestParameters.isVisible, requestParameters.libraryId, requestParameters.livePhotoData, requestParameters.sidecarData, options).then((request) => request(axios, basePath)); }, }; }; @@ -7727,6 +8003,20 @@ export interface AssetApiUploadFileRequest { */ readonly isArchived?: boolean + /** + * + * @type {boolean} + * @memberof AssetApiUploadFile + */ + readonly isExternal?: boolean + + /** + * + * @type {boolean} + * @memberof AssetApiUploadFile + */ + readonly isOffline?: boolean + /** * * @type {boolean} @@ -7741,6 +8031,13 @@ export interface AssetApiUploadFileRequest { */ readonly isVisible?: boolean + /** + * + * @type {string} + * @memberof AssetApiUploadFile + */ + readonly libraryId?: string + /** * * @type {File} @@ -8043,7 +8340,7 @@ export class AssetApi extends BaseAPI { * @memberof AssetApi */ public uploadFile(requestParameters: AssetApiUploadFileRequest, options?: AxiosRequestConfig) { - return AssetApiFp(this.configuration).uploadFile(requestParameters.assetData, requestParameters.deviceAssetId, requestParameters.deviceId, requestParameters.fileCreatedAt, requestParameters.fileModifiedAt, requestParameters.isFavorite, requestParameters.key, requestParameters.duration, requestParameters.isArchived, requestParameters.isReadOnly, requestParameters.isVisible, requestParameters.livePhotoData, requestParameters.sidecarData, options).then((request) => request(this.axios, this.basePath)); + return AssetApiFp(this.configuration).uploadFile(requestParameters.assetData, requestParameters.deviceAssetId, requestParameters.deviceId, requestParameters.fileCreatedAt, requestParameters.fileModifiedAt, requestParameters.isFavorite, requestParameters.key, requestParameters.duration, requestParameters.isArchived, requestParameters.isExternal, requestParameters.isOffline, requestParameters.isReadOnly, requestParameters.isVisible, requestParameters.libraryId, requestParameters.livePhotoData, requestParameters.sidecarData, options).then((request) => request(this.axios, this.basePath)); } } @@ -9038,6 +9335,741 @@ export class JobApi extends BaseAPI { } +/** + * LibraryApi - axios parameter creator + * @export + */ +export const LibraryApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * + * @param {CreateLibraryDto} createLibraryDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createLibrary: async (createLibraryDto: CreateLibraryDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'createLibraryDto' is not null or undefined + assertParamExists('createLibrary', 'createLibraryDto', createLibraryDto) + const localVarPath = `/library`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(createLibraryDto, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + deleteLibrary: async (id: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('deleteLibrary', 'id', id) + const localVarPath = `/library/{id}` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getAllForUser: async (options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/library`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getLibraryInfo: async (id: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('getLibraryInfo', 'id', id) + const localVarPath = `/library/{id}` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getLibraryStatistics: async (id: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('getLibraryStatistics', 'id', id) + const localVarPath = `/library/{id}/statistics` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + removeOfflineFiles: async (id: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('removeOfflineFiles', 'id', id) + const localVarPath = `/library/{id}/removeOffline` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} id + * @param {ScanLibraryDto} scanLibraryDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + scanLibrary: async (id: string, scanLibraryDto: ScanLibraryDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('scanLibrary', 'id', id) + // verify required parameter 'scanLibraryDto' is not null or undefined + assertParamExists('scanLibrary', 'scanLibraryDto', scanLibraryDto) + const localVarPath = `/library/{id}/scan` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(scanLibraryDto, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} id + * @param {UpdateLibraryDto} updateLibraryDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + updateLibrary: async (id: string, updateLibraryDto: UpdateLibraryDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('updateLibrary', 'id', id) + // verify required parameter 'updateLibraryDto' is not null or undefined + assertParamExists('updateLibrary', 'updateLibraryDto', updateLibraryDto) + const localVarPath = `/library/{id}` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(updateLibraryDto, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * LibraryApi - functional programming interface + * @export + */ +export const LibraryApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = LibraryApiAxiosParamCreator(configuration) + return { + /** + * + * @param {CreateLibraryDto} createLibraryDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async createLibrary(createLibraryDto: CreateLibraryDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.createLibrary(createLibraryDto, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async deleteLibrary(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.deleteLibrary(id, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getAllForUser(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getAllForUser(options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getLibraryInfo(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getLibraryInfo(id, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getLibraryStatistics(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getLibraryStatistics(id, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async removeOfflineFiles(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.removeOfflineFiles(id, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {string} id + * @param {ScanLibraryDto} scanLibraryDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async scanLibrary(id: string, scanLibraryDto: ScanLibraryDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.scanLibrary(id, scanLibraryDto, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {string} id + * @param {UpdateLibraryDto} updateLibraryDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async updateLibrary(id: string, updateLibraryDto: UpdateLibraryDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.updateLibrary(id, updateLibraryDto, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + } +}; + +/** + * LibraryApi - factory interface + * @export + */ +export const LibraryApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = LibraryApiFp(configuration) + return { + /** + * + * @param {LibraryApiCreateLibraryRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createLibrary(requestParameters: LibraryApiCreateLibraryRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.createLibrary(requestParameters.createLibraryDto, options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {LibraryApiDeleteLibraryRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + deleteLibrary(requestParameters: LibraryApiDeleteLibraryRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.deleteLibrary(requestParameters.id, options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getAllForUser(options?: AxiosRequestConfig): AxiosPromise> { + return localVarFp.getAllForUser(options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {LibraryApiGetLibraryInfoRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getLibraryInfo(requestParameters: LibraryApiGetLibraryInfoRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.getLibraryInfo(requestParameters.id, options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {LibraryApiGetLibraryStatisticsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getLibraryStatistics(requestParameters: LibraryApiGetLibraryStatisticsRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.getLibraryStatistics(requestParameters.id, options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {LibraryApiRemoveOfflineFilesRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + removeOfflineFiles(requestParameters: LibraryApiRemoveOfflineFilesRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.removeOfflineFiles(requestParameters.id, options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {LibraryApiScanLibraryRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + scanLibrary(requestParameters: LibraryApiScanLibraryRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.scanLibrary(requestParameters.id, requestParameters.scanLibraryDto, options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {LibraryApiUpdateLibraryRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + updateLibrary(requestParameters: LibraryApiUpdateLibraryRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.updateLibrary(requestParameters.id, requestParameters.updateLibraryDto, options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * Request parameters for createLibrary operation in LibraryApi. + * @export + * @interface LibraryApiCreateLibraryRequest + */ +export interface LibraryApiCreateLibraryRequest { + /** + * + * @type {CreateLibraryDto} + * @memberof LibraryApiCreateLibrary + */ + readonly createLibraryDto: CreateLibraryDto +} + +/** + * Request parameters for deleteLibrary operation in LibraryApi. + * @export + * @interface LibraryApiDeleteLibraryRequest + */ +export interface LibraryApiDeleteLibraryRequest { + /** + * + * @type {string} + * @memberof LibraryApiDeleteLibrary + */ + readonly id: string +} + +/** + * Request parameters for getLibraryInfo operation in LibraryApi. + * @export + * @interface LibraryApiGetLibraryInfoRequest + */ +export interface LibraryApiGetLibraryInfoRequest { + /** + * + * @type {string} + * @memberof LibraryApiGetLibraryInfo + */ + readonly id: string +} + +/** + * Request parameters for getLibraryStatistics operation in LibraryApi. + * @export + * @interface LibraryApiGetLibraryStatisticsRequest + */ +export interface LibraryApiGetLibraryStatisticsRequest { + /** + * + * @type {string} + * @memberof LibraryApiGetLibraryStatistics + */ + readonly id: string +} + +/** + * Request parameters for removeOfflineFiles operation in LibraryApi. + * @export + * @interface LibraryApiRemoveOfflineFilesRequest + */ +export interface LibraryApiRemoveOfflineFilesRequest { + /** + * + * @type {string} + * @memberof LibraryApiRemoveOfflineFiles + */ + readonly id: string +} + +/** + * Request parameters for scanLibrary operation in LibraryApi. + * @export + * @interface LibraryApiScanLibraryRequest + */ +export interface LibraryApiScanLibraryRequest { + /** + * + * @type {string} + * @memberof LibraryApiScanLibrary + */ + readonly id: string + + /** + * + * @type {ScanLibraryDto} + * @memberof LibraryApiScanLibrary + */ + readonly scanLibraryDto: ScanLibraryDto +} + +/** + * Request parameters for updateLibrary operation in LibraryApi. + * @export + * @interface LibraryApiUpdateLibraryRequest + */ +export interface LibraryApiUpdateLibraryRequest { + /** + * + * @type {string} + * @memberof LibraryApiUpdateLibrary + */ + readonly id: string + + /** + * + * @type {UpdateLibraryDto} + * @memberof LibraryApiUpdateLibrary + */ + readonly updateLibraryDto: UpdateLibraryDto +} + +/** + * LibraryApi - object-oriented interface + * @export + * @class LibraryApi + * @extends {BaseAPI} + */ +export class LibraryApi extends BaseAPI { + /** + * + * @param {LibraryApiCreateLibraryRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof LibraryApi + */ + public createLibrary(requestParameters: LibraryApiCreateLibraryRequest, options?: AxiosRequestConfig) { + return LibraryApiFp(this.configuration).createLibrary(requestParameters.createLibraryDto, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {LibraryApiDeleteLibraryRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof LibraryApi + */ + public deleteLibrary(requestParameters: LibraryApiDeleteLibraryRequest, options?: AxiosRequestConfig) { + return LibraryApiFp(this.configuration).deleteLibrary(requestParameters.id, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof LibraryApi + */ + public getAllForUser(options?: AxiosRequestConfig) { + return LibraryApiFp(this.configuration).getAllForUser(options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {LibraryApiGetLibraryInfoRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof LibraryApi + */ + public getLibraryInfo(requestParameters: LibraryApiGetLibraryInfoRequest, options?: AxiosRequestConfig) { + return LibraryApiFp(this.configuration).getLibraryInfo(requestParameters.id, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {LibraryApiGetLibraryStatisticsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof LibraryApi + */ + public getLibraryStatistics(requestParameters: LibraryApiGetLibraryStatisticsRequest, options?: AxiosRequestConfig) { + return LibraryApiFp(this.configuration).getLibraryStatistics(requestParameters.id, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {LibraryApiRemoveOfflineFilesRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof LibraryApi + */ + public removeOfflineFiles(requestParameters: LibraryApiRemoveOfflineFilesRequest, options?: AxiosRequestConfig) { + return LibraryApiFp(this.configuration).removeOfflineFiles(requestParameters.id, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {LibraryApiScanLibraryRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof LibraryApi + */ + public scanLibrary(requestParameters: LibraryApiScanLibraryRequest, options?: AxiosRequestConfig) { + return LibraryApiFp(this.configuration).scanLibrary(requestParameters.id, requestParameters.scanLibraryDto, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {LibraryApiUpdateLibraryRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof LibraryApi + */ + public updateLibrary(requestParameters: LibraryApiUpdateLibraryRequest, options?: AxiosRequestConfig) { + return LibraryApiFp(this.configuration).updateLibrary(requestParameters.id, requestParameters.updateLibraryDto, options).then((request) => request(this.axios, this.basePath)); + } +} + + /** * OAuthApi - axios parameter creator * @export diff --git a/web/src/lib/components/admin-page/jobs/jobs-panel.svelte b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte index 38230afd29..8b4cb3d896 100644 --- a/web/src/lib/components/admin-page/jobs/jobs-panel.svelte +++ b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte @@ -12,6 +12,7 @@ import FaceRecognition from 'svelte-material-icons/FaceRecognition.svelte'; import FileJpgBox from 'svelte-material-icons/FileJpgBox.svelte'; import FileXmlBox from 'svelte-material-icons/FileXmlBox.svelte'; + import LibraryShelves from 'svelte-material-icons/LibraryShelves.svelte'; import FolderMove from 'svelte-material-icons/FolderMove.svelte'; import CogIcon from 'svelte-material-icons/Cog.svelte'; import Table from 'svelte-material-icons/Table.svelte'; @@ -64,6 +65,13 @@ title: api.getJobName(JobName.MetadataExtraction), subtitle: 'Extract metadata information i.e. GPS, resolution...etc', }, + [JobName.Library]: { + icon: LibraryShelves, + title: api.getJobName(JobName.Library), + subtitle: 'Perform library tasks', + allText: 'ALL', + missingText: 'REFRESH', + }, [JobName.Sidecar]: { title: api.getJobName(JobName.Sidecar), icon: FileXmlBox, diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index ec7218ebbd..6f035c051e 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -6,6 +6,7 @@ import { createEventDispatcher } from 'svelte'; import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte'; import CloudDownloadOutline from 'svelte-material-icons/CloudDownloadOutline.svelte'; + import AlertOutline from 'svelte-material-icons/AlertOutline.svelte'; import ContentCopy from 'svelte-material-icons/ContentCopy.svelte'; import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte'; import DotsVertical from 'svelte-material-icons/DotsVertical.svelte'; @@ -74,6 +75,14 @@ dispatch('goBack')} />
+ {#if asset.isOffline} + dispatch('showDetail')} + title="Asset Offline" + /> + {/if} {#if showMotionPlayButton} {#if isMotionPhotoPlaying} dispatch('delete')} title="Delete" /> + {#if !asset.isReadOnly && !asset.isExternal} + dispatch('delete')} title="Delete" /> + {/if}
(isShowAssetOptions = false)}> {#if isShowAssetOptions} diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index a2dbe8e1c6..fd156d6225 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -50,7 +50,7 @@ let addToSharedAlbum = true; let shouldPlayMotionPhoto = false; let isShowProfileImageCrop = false; - let shouldShowDownloadButton = sharedLink ? sharedLink.allowDownload : true; + let shouldShowDownloadButton = sharedLink ? sharedLink.allowDownload : !asset.isOffline; let canCopyImagesToClipboard: boolean; const onKeyboardPress = (keyInfo: KeyboardEvent) => handleKeyboardPress(keyInfo); diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 3025b3d615..8436892e76 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -101,6 +101,20 @@

Info

+ {#if asset.isOffline} +
+
+
Asset offline
+
+

+ This asset is offline. Immich can not access its file location. Please ensure the asset is available and + then rescan the library. +

+
+
+
+ {/if} +