diff --git a/docs/docs/features/img/sidecar-jobs.png b/docs/docs/features/img/sidecar-jobs.png new file mode 100644 index 0000000000000..efa8bce82bc76 Binary files /dev/null and b/docs/docs/features/img/sidecar-jobs.png differ diff --git a/docs/docs/features/img/xmp-sidecars.png b/docs/docs/features/img/xmp-sidecars.png new file mode 100644 index 0000000000000..c19697c62bceb Binary files /dev/null and b/docs/docs/features/img/xmp-sidecars.png differ diff --git a/docs/docs/features/xmp-sidecars.md b/docs/docs/features/xmp-sidecars.md new file mode 100644 index 0000000000000..e118c1de5bdb5 --- /dev/null +++ b/docs/docs/features/xmp-sidecars.md @@ -0,0 +1,13 @@ +# XMP Sidecars + +Immich can ingest XMP sidecars on file upload (via the CLI) as well as detect new sidecars that are placed in the filesystem for existing images. + + + +XMP sidecars are external XML files that contain metadata related to media files. Many applications read and write these files either exclusively or in addition to the metadata written to image files. They can be a powerful tool for editing and storing metadata of a media file without modifying the mdia file itself. When Immich receives or detects an XMP sidecar for a media file, it will attempt to extract the metadata from both the sidecar as well as the media file. It will prioritize the metadata for fields in the sidecar but will fall back and use the metadata in the media file if necessary. + +When importing files via the CLI bulk uploader, Immich will automatically detect XMP sidecar files as files that exist next to the original media file and have the exact same name with an additional `.xmp` file extension (i.e., `PXL_20230401_203352928.MP.jpg` and `PXL_20230401_203352928.MP.jpg.xmp`). + +There are 2 administrator jobs associated with sidecar files: `SYNC` and `DISCOVER`. The sync job will re-scan all media with existing sidecar files and queue them for a metadata refresh. This is a great use case when third-party applications are used to modify the metadata of media. The discover job will attempt to scan the filesystem for new sidecar files for all media that does not currently have a sidecar file associated with it. + + diff --git a/mobile/openapi/doc/AllJobStatusResponseDto.md b/mobile/openapi/doc/AllJobStatusResponseDto.md index cac4b0f44b83f..5578b44a0e664 100644 --- a/mobile/openapi/doc/AllJobStatusResponseDto.md +++ b/mobile/openapi/doc/AllJobStatusResponseDto.md @@ -17,6 +17,7 @@ Name | Type | Description | Notes **backgroundTaskQueue** | [**JobStatusDto**](JobStatusDto.md) | | **searchQueue** | [**JobStatusDto**](JobStatusDto.md) | | **recognizeFacesQueue** | [**JobStatusDto**](JobStatusDto.md) | | +**sidecarQueue** | [**JobStatusDto**](JobStatusDto.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/AssetApi.md b/mobile/openapi/doc/AssetApi.md index fbe6d02a70cda..ed78743548b7e 100644 --- a/mobile/openapi/doc/AssetApi.md +++ b/mobile/openapi/doc/AssetApi.md @@ -1443,7 +1443,7 @@ Name | Type | Description | Notes [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) # **uploadFile** -> AssetFileUploadResponseDto uploadFile(assetType, assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, fileExtension, key, livePhotoData, isArchived, isVisible, duration) +> AssetFileUploadResponseDto uploadFile(assetType, assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, fileExtension, key, livePhotoData, sidecarData, isArchived, isVisible, duration) @@ -1476,12 +1476,13 @@ final isFavorite = true; // bool | final fileExtension = fileExtension_example; // String | final key = key_example; // String | final livePhotoData = BINARY_DATA_HERE; // MultipartFile | +final sidecarData = BINARY_DATA_HERE; // MultipartFile | final isArchived = true; // bool | final isVisible = true; // bool | final duration = duration_example; // String | try { - final result = api_instance.uploadFile(assetType, assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, fileExtension, key, livePhotoData, isArchived, isVisible, duration); + final result = api_instance.uploadFile(assetType, assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, fileExtension, key, livePhotoData, sidecarData, isArchived, isVisible, duration); print(result); } catch (e) { print('Exception when calling AssetApi->uploadFile: $e\n'); @@ -1502,6 +1503,7 @@ Name | Type | Description | Notes **fileExtension** | **String**| | **key** | **String**| | [optional] **livePhotoData** | **MultipartFile**| | [optional] + **sidecarData** | **MultipartFile**| | [optional] **isArchived** | **bool**| | [optional] **isVisible** | **bool**| | [optional] **duration** | **String**| | [optional] diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index 44acdb29197e4..1bde075f37b3d 100644 --- a/mobile/openapi/lib/api/asset_api.dart +++ b/mobile/openapi/lib/api/asset_api.dart @@ -1396,12 +1396,14 @@ class AssetApi { /// /// * [MultipartFile] livePhotoData: /// + /// * [MultipartFile] sidecarData: + /// /// * [bool] isArchived: /// /// * [bool] isVisible: /// /// * [String] duration: - Future uploadFileWithHttpInfo(AssetTypeEnum assetType, MultipartFile assetData, String deviceAssetId, String deviceId, String fileCreatedAt, String fileModifiedAt, bool isFavorite, String fileExtension, { String? key, MultipartFile? livePhotoData, bool? isArchived, bool? isVisible, String? duration, }) async { + Future uploadFileWithHttpInfo(AssetTypeEnum assetType, MultipartFile assetData, String deviceAssetId, String deviceId, String fileCreatedAt, String fileModifiedAt, bool isFavorite, String fileExtension, { String? key, MultipartFile? livePhotoData, MultipartFile? sidecarData, bool? isArchived, bool? isVisible, String? duration, }) async { // ignore: prefer_const_declarations final path = r'/asset/upload'; @@ -1434,6 +1436,11 @@ class AssetApi { mp.fields[r'livePhotoData'] = livePhotoData.field; mp.files.add(livePhotoData); } + if (sidecarData != null) { + hasFields = true; + mp.fields[r'sidecarData'] = sidecarData.field; + mp.files.add(sidecarData); + } if (deviceAssetId != null) { hasFields = true; mp.fields[r'deviceAssetId'] = parameterToString(deviceAssetId); @@ -1507,13 +1514,15 @@ class AssetApi { /// /// * [MultipartFile] livePhotoData: /// + /// * [MultipartFile] sidecarData: + /// /// * [bool] isArchived: /// /// * [bool] isVisible: /// /// * [String] duration: - Future uploadFile(AssetTypeEnum assetType, MultipartFile assetData, String deviceAssetId, String deviceId, String fileCreatedAt, String fileModifiedAt, bool isFavorite, String fileExtension, { String? key, MultipartFile? livePhotoData, bool? isArchived, bool? isVisible, String? duration, }) async { - final response = await uploadFileWithHttpInfo(assetType, assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, fileExtension, key: key, livePhotoData: livePhotoData, isArchived: isArchived, isVisible: isVisible, duration: duration, ); + Future uploadFile(AssetTypeEnum assetType, MultipartFile assetData, String deviceAssetId, String deviceId, String fileCreatedAt, String fileModifiedAt, bool isFavorite, String fileExtension, { String? key, MultipartFile? livePhotoData, MultipartFile? sidecarData, bool? isArchived, bool? isVisible, String? duration, }) async { + final response = await uploadFileWithHttpInfo(assetType, assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, fileExtension, key: key, livePhotoData: livePhotoData, sidecarData: sidecarData, isArchived: isArchived, isVisible: isVisible, duration: duration, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } 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 bcf1990fac0b9..b438bf4e9f1b0 100644 --- a/mobile/openapi/lib/model/all_job_status_response_dto.dart +++ b/mobile/openapi/lib/model/all_job_status_response_dto.dart @@ -22,6 +22,7 @@ class AllJobStatusResponseDto { required this.backgroundTaskQueue, required this.searchQueue, required this.recognizeFacesQueue, + required this.sidecarQueue, }); JobStatusDto thumbnailGenerationQueue; @@ -42,6 +43,8 @@ class AllJobStatusResponseDto { JobStatusDto recognizeFacesQueue; + JobStatusDto sidecarQueue; + @override bool operator ==(Object other) => identical(this, other) || other is AllJobStatusResponseDto && other.thumbnailGenerationQueue == thumbnailGenerationQueue && @@ -52,7 +55,8 @@ class AllJobStatusResponseDto { other.storageTemplateMigrationQueue == storageTemplateMigrationQueue && other.backgroundTaskQueue == backgroundTaskQueue && other.searchQueue == searchQueue && - other.recognizeFacesQueue == recognizeFacesQueue; + other.recognizeFacesQueue == recognizeFacesQueue && + other.sidecarQueue == sidecarQueue; @override int get hashCode => @@ -65,10 +69,11 @@ class AllJobStatusResponseDto { (storageTemplateMigrationQueue.hashCode) + (backgroundTaskQueue.hashCode) + (searchQueue.hashCode) + - (recognizeFacesQueue.hashCode); + (recognizeFacesQueue.hashCode) + + (sidecarQueue.hashCode); @override - String toString() => 'AllJobStatusResponseDto[thumbnailGenerationQueue=$thumbnailGenerationQueue, metadataExtractionQueue=$metadataExtractionQueue, videoConversionQueue=$videoConversionQueue, objectTaggingQueue=$objectTaggingQueue, clipEncodingQueue=$clipEncodingQueue, storageTemplateMigrationQueue=$storageTemplateMigrationQueue, backgroundTaskQueue=$backgroundTaskQueue, searchQueue=$searchQueue, recognizeFacesQueue=$recognizeFacesQueue]'; + String toString() => 'AllJobStatusResponseDto[thumbnailGenerationQueue=$thumbnailGenerationQueue, metadataExtractionQueue=$metadataExtractionQueue, videoConversionQueue=$videoConversionQueue, objectTaggingQueue=$objectTaggingQueue, clipEncodingQueue=$clipEncodingQueue, storageTemplateMigrationQueue=$storageTemplateMigrationQueue, backgroundTaskQueue=$backgroundTaskQueue, searchQueue=$searchQueue, recognizeFacesQueue=$recognizeFacesQueue, sidecarQueue=$sidecarQueue]'; Map toJson() { final json = {}; @@ -81,6 +86,7 @@ class AllJobStatusResponseDto { json[r'background-task-queue'] = this.backgroundTaskQueue; json[r'search-queue'] = this.searchQueue; json[r'recognize-faces-queue'] = this.recognizeFacesQueue; + json[r'sidecar-queue'] = this.sidecarQueue; return json; } @@ -112,6 +118,7 @@ class AllJobStatusResponseDto { backgroundTaskQueue: JobStatusDto.fromJson(json[r'background-task-queue'])!, searchQueue: JobStatusDto.fromJson(json[r'search-queue'])!, recognizeFacesQueue: JobStatusDto.fromJson(json[r'recognize-faces-queue'])!, + sidecarQueue: JobStatusDto.fromJson(json[r'sidecar-queue'])!, ); } return null; @@ -168,6 +175,7 @@ class AllJobStatusResponseDto { 'background-task-queue', 'search-queue', 'recognize-faces-queue', + 'sidecar-queue', }; } diff --git a/mobile/openapi/lib/model/job_name.dart b/mobile/openapi/lib/model/job_name.dart index b76e44e3ff25f..5226e967ddf56 100644 --- a/mobile/openapi/lib/model/job_name.dart +++ b/mobile/openapi/lib/model/job_name.dart @@ -32,6 +32,7 @@ class JobName { static const backgroundTaskQueue = JobName._(r'background-task-queue'); static const storageTemplateMigrationQueue = JobName._(r'storage-template-migration-queue'); static const searchQueue = JobName._(r'search-queue'); + static const sidecarQueue = JobName._(r'sidecar-queue'); /// List of all possible values in this [enum][JobName]. static const values = [ @@ -44,6 +45,7 @@ class JobName { backgroundTaskQueue, storageTemplateMigrationQueue, searchQueue, + sidecarQueue, ]; static JobName? fromJson(dynamic value) => JobNameTypeTransformer().decode(value); @@ -91,6 +93,7 @@ class JobNameTypeTransformer { case r'background-task-queue': return JobName.backgroundTaskQueue; case r'storage-template-migration-queue': return JobName.storageTemplateMigrationQueue; case r'search-queue': return JobName.searchQueue; + case r'sidecar-queue': return JobName.sidecarQueue; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); 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 eff589c9a8ef5..437ed3d1dac5b 100644 --- a/mobile/openapi/test/all_job_status_response_dto_test.dart +++ b/mobile/openapi/test/all_job_status_response_dto_test.dart @@ -61,6 +61,11 @@ void main() { // TODO }); + // JobStatusDto sidecarQueue + test('to test the property `sidecarQueue`', () async { + // TODO + }); + }); diff --git a/mobile/openapi/test/asset_api_test.dart b/mobile/openapi/test/asset_api_test.dart index af0bc44c25e1a..91c3d613c0a52 100644 --- a/mobile/openapi/test/asset_api_test.dart +++ b/mobile/openapi/test/asset_api_test.dart @@ -158,7 +158,7 @@ void main() { // TODO }); - //Future uploadFile(AssetTypeEnum assetType, MultipartFile assetData, String deviceAssetId, String deviceId, String fileCreatedAt, String fileModifiedAt, bool isFavorite, String fileExtension, { String key, MultipartFile livePhotoData, bool isArchived, bool isVisible, String duration }) async + //Future uploadFile(AssetTypeEnum assetType, MultipartFile assetData, String deviceAssetId, String deviceId, String fileCreatedAt, String fileModifiedAt, bool isFavorite, String fileExtension, { String key, MultipartFile livePhotoData, MultipartFile sidecarData, bool isArchived, bool isVisible, String duration }) async test('test uploadFile', () async { // TODO }); diff --git a/server/apps/immich/src/api-v1/asset/asset.controller.ts b/server/apps/immich/src/api-v1/asset/asset.controller.ts index 774a72ea9be76..6e31d8fd4eb6d 100644 --- a/server/apps/immich/src/api-v1/asset/asset.controller.ts +++ b/server/apps/immich/src/api-v1/asset/asset.controller.ts @@ -78,6 +78,7 @@ export class AssetController { [ { name: 'assetData', maxCount: 1 }, { name: 'livePhotoData', maxCount: 1 }, + { name: 'sidecarData', maxCount: 1 }, ], assetUploadOption, ), @@ -90,18 +91,24 @@ export class AssetController { async uploadFile( @GetAuthUser() authUser: AuthUserDto, @UploadedFiles(new ParseFilePipe({ validators: [new FileNotEmptyValidator(['assetData'])] })) - files: { assetData: ImmichFile[]; livePhotoData?: ImmichFile[] }, + files: { assetData: ImmichFile[]; livePhotoData?: ImmichFile[]; sidecarData: ImmichFile[] }, @Body(new ValidationPipe()) dto: CreateAssetDto, @Response({ passthrough: true }) res: Res, ): Promise { const file = mapToUploadFile(files.assetData[0]); const _livePhotoFile = files.livePhotoData?.[0]; + const _sidecarFile = files.sidecarData?.[0]; let livePhotoFile; if (_livePhotoFile) { livePhotoFile = mapToUploadFile(_livePhotoFile); } - const responseDto = await this.assetService.uploadFile(authUser, dto, file, livePhotoFile); + let sidecarFile; + if (_sidecarFile) { + sidecarFile = mapToUploadFile(_sidecarFile); + } + + const responseDto = await this.assetService.uploadFile(authUser, dto, file, livePhotoFile, sidecarFile); if (responseDto.duplicate) { res.status(200); } diff --git a/server/apps/immich/src/api-v1/asset/asset.core.ts b/server/apps/immich/src/api-v1/asset/asset.core.ts index 34e014fc724b1..5d1a1b50b1073 100644 --- a/server/apps/immich/src/api-v1/asset/asset.core.ts +++ b/server/apps/immich/src/api-v1/asset/asset.core.ts @@ -12,6 +12,7 @@ export class AssetCore { dto: CreateAssetDto, file: UploadFile, livePhotoAssetId?: string, + sidecarFile?: UploadFile, ): Promise { const asset = await this.repository.create({ owner: { id: authUser.id } as UserEntity, @@ -39,6 +40,7 @@ export class AssetCore { sharedLinks: [], originalFileName: parse(file.originalName).name, faces: [], + sidecarPath: sidecarFile?.originalPath || null, }); await this.jobRepository.queue({ name: JobName.ASSET_UPLOADED, data: { asset } }); diff --git a/server/apps/immich/src/api-v1/asset/asset.service.spec.ts b/server/apps/immich/src/api-v1/asset/asset.service.spec.ts index cacacc606a0c4..0a0fa9052f145 100644 --- a/server/apps/immich/src/api-v1/asset/asset.service.spec.ts +++ b/server/apps/immich/src/api-v1/asset/asset.service.spec.ts @@ -305,7 +305,7 @@ describe('AssetService', () => { expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.DELETE_FILES, - data: { files: ['fake_path/asset_1.jpeg', undefined] }, + data: { files: ['fake_path/asset_1.jpeg', undefined, undefined] }, }); expect(storageMock.moveFile).not.toHaveBeenCalled(); }); @@ -413,10 +413,12 @@ describe('AssetService', () => { undefined, undefined, undefined, + undefined, 'fake_path/asset_1.mp4', undefined, undefined, undefined, + undefined, ], }, }); @@ -462,10 +464,12 @@ describe('AssetService', () => { 'web-path-1', 'resize-path-1', undefined, + undefined, 'original-path-2', 'web-path-2', 'resize-path-2', 'encoded-video-path-2', + undefined, ], }, }, diff --git a/server/apps/immich/src/api-v1/asset/asset.service.ts b/server/apps/immich/src/api-v1/asset/asset.service.ts index 145cd717dea85..66fa0919132e5 100644 --- a/server/apps/immich/src/api-v1/asset/asset.service.ts +++ b/server/apps/immich/src/api-v1/asset/asset.service.ts @@ -106,6 +106,7 @@ export class AssetService { dto: CreateAssetDto, file: UploadFile, livePhotoFile?: UploadFile, + sidecarFile?: UploadFile, ): Promise { if (livePhotoFile) { livePhotoFile = { @@ -122,14 +123,14 @@ export class AssetService { livePhotoAsset = await this.assetCore.create(authUser, livePhotoDto, livePhotoFile); } - const asset = await this.assetCore.create(authUser, dto, file, livePhotoAsset?.id); + const asset = await this.assetCore.create(authUser, dto, file, livePhotoAsset?.id, sidecarFile); return { id: asset.id, duplicate: false }; } catch (error: any) { // clean up files await this.jobRepository.queue({ name: JobName.DELETE_FILES, - data: { files: [file.originalPath, livePhotoFile?.originalPath] }, + data: { files: [file.originalPath, livePhotoFile?.originalPath, sidecarFile?.originalPath] }, }); // handle duplicates with a success response @@ -366,7 +367,13 @@ export class AssetService { await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [id] } }); result.push({ id, status: DeleteAssetStatusEnum.SUCCESS }); - deleteQueue.push(asset.originalPath, asset.webpPath, asset.resizePath, asset.encodedVideoPath); + deleteQueue.push( + asset.originalPath, + asset.webpPath, + asset.resizePath, + asset.encodedVideoPath, + asset.sidecarPath, + ); // TODO refactor this to use cascades if (asset.livePhotoVideoId && !ids.includes(asset.livePhotoVideoId)) { diff --git a/server/apps/immich/src/api-v1/asset/dto/create-asset.dto.ts b/server/apps/immich/src/api-v1/asset/dto/create-asset.dto.ts index cf158a7e2321c..582b1249aab7b 100644 --- a/server/apps/immich/src/api-v1/asset/dto/create-asset.dto.ts +++ b/server/apps/immich/src/api-v1/asset/dto/create-asset.dto.ts @@ -45,6 +45,9 @@ export class CreateAssetDto { @ApiProperty({ type: 'string', format: 'binary' }) livePhotoData?: any; + + @ApiProperty({ type: 'string', format: 'binary' }) + sidecarData?: any; } export interface UploadFile { diff --git a/server/apps/immich/src/config/asset-upload.config.ts b/server/apps/immich/src/config/asset-upload.config.ts index cdf4bd60bf5a5..0a1d615130f9d 100644 --- a/server/apps/immich/src/config/asset-upload.config.ts +++ b/server/apps/immich/src/config/asset-upload.config.ts @@ -60,6 +60,11 @@ function fileFilter(req: AuthRequest, file: any, cb: any) { ) { cb(null, true); } else { + // Additionally support XML but only for sidecar files + if (file.fieldname == 'sidecarData' && file.mimetype.match(/\/xml$/)) { + return cb(null, true); + } + logger.error(`Unsupported file type ${extname(file.originalname)} file MIME type ${file.mimetype}`); cb(new BadRequestException(`Unsupported file type ${extname(file.originalname)}`), false); } @@ -95,6 +100,11 @@ function filename(req: AuthRequest, file: Express.Multer.File, cb: any) { return cb(null, sanitize(livePhotoFileName)); } + if (file.fieldname === 'sidecarData') { + const sidecarFileName = `${fileNameUUID}.xmp`; + return cb(null, sanitize(sidecarFileName)); + } + const fileName = `${fileNameUUID}${req.body['fileExtension']}`; return cb(null, sanitize(fileName)); } diff --git a/server/apps/microservices/src/microservices.module.ts b/server/apps/microservices/src/microservices.module.ts index 76d7957964fa5..dc421d5f17e82 100644 --- a/server/apps/microservices/src/microservices.module.ts +++ b/server/apps/microservices/src/microservices.module.ts @@ -13,7 +13,7 @@ import { ThumbnailGeneratorProcessor, VideoTranscodeProcessor, } from './processors'; -import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor'; +import { MetadataExtractionProcessor, SidecarProcessor } from './processors/metadata-extraction.processor'; @Module({ imports: [ @@ -31,6 +31,7 @@ import { MetadataExtractionProcessor } from './processors/metadata-extraction.pr BackgroundTaskProcessor, SearchIndexProcessor, FacialRecognitionProcessor, + SidecarProcessor, ], }) export class MicroservicesModule {} diff --git a/server/apps/microservices/src/processors/metadata-extraction.processor.ts b/server/apps/microservices/src/processors/metadata-extraction.processor.ts index 47facbaefed21..5cb1ca76e83fa 100644 --- a/server/apps/microservices/src/processors/metadata-extraction.processor.ts +++ b/server/apps/microservices/src/processors/metadata-extraction.processor.ts @@ -10,6 +10,7 @@ import { QueueName, usePagination, WithoutProperty, + WithProperty, } from '@app/domain'; import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities'; import { Process, Processor } from '@nestjs/bull'; @@ -98,13 +99,22 @@ export class MetadataExtractionProcessor { let asset = job.data.asset; try { - const exifData = await exiftool.read(asset.originalPath).catch((error: any) => { + const mediaExifData = await exiftool.read(asset.originalPath).catch((error: any) => { this.logger.warn( `The exifData parsing failed due to ${error} for asset ${asset.id} at ${asset.originalPath}`, error?.stack, ); return null; }); + const sidecarExifData = asset.sidecarPath + ? await exiftool.read(asset.sidecarPath).catch((error: any) => { + this.logger.warn( + `The exifData parsing failed due to ${error} for asset ${asset.id} at ${asset.originalPath}`, + error?.stack, + ); + return null; + }) + : {}; const exifToDate = (exifDate: string | ExifDateTime | undefined) => { if (!exifDate) return null; @@ -126,31 +136,46 @@ export class MetadataExtractionProcessor { return exifDate.zone ?? null; }; - const timeZone = exifTimeZone(exifData?.DateTimeOriginal ?? exifData?.CreateDate ?? asset.fileCreatedAt); - const fileCreatedAt = exifToDate(exifData?.DateTimeOriginal ?? exifData?.CreateDate ?? asset.fileCreatedAt); - const fileModifiedAt = exifToDate(exifData?.ModifyDate ?? asset.fileModifiedAt); + const getExifProperty = (...properties: T[]): any | null => { + for (const property of properties) { + const value = sidecarExifData?.[property] ?? mediaExifData?.[property]; + if (value !== null && value !== undefined) { + return value; + } + } + + return null; + }; + + const timeZone = exifTimeZone(getExifProperty('DateTimeOriginal', 'CreateDate') ?? asset.fileCreatedAt); + const fileCreatedAt = exifToDate(getExifProperty('DateTimeOriginal', 'CreateDate') ?? asset.fileCreatedAt); + const fileModifiedAt = exifToDate(getExifProperty('ModifyDate') ?? asset.fileModifiedAt); const fileStats = fs.statSync(asset.originalPath); const fileSizeInBytes = fileStats.size; const newExif = new ExifEntity(); newExif.assetId = asset.id; newExif.fileSizeInByte = fileSizeInBytes; - newExif.make = exifData?.Make || null; - newExif.model = exifData?.Model || null; - newExif.exifImageHeight = exifData?.ExifImageHeight || exifData?.ImageHeight || null; - newExif.exifImageWidth = exifData?.ExifImageWidth || exifData?.ImageWidth || null; - newExif.exposureTime = exifData?.ExposureTime || null; - newExif.orientation = exifData?.Orientation?.toString() || null; + newExif.make = getExifProperty('Make'); + newExif.model = getExifProperty('Model'); + newExif.exifImageHeight = getExifProperty('ExifImageHeight', 'ImageHeight'); + newExif.exifImageWidth = getExifProperty('ExifImageWidth', 'ImageWidth'); + newExif.exposureTime = getExifProperty('ExposureTime'); + newExif.orientation = getExifProperty('Orientation')?.toString(); newExif.dateTimeOriginal = fileCreatedAt; newExif.modifyDate = fileModifiedAt; newExif.timeZone = timeZone; - newExif.lensModel = exifData?.LensModel || null; - newExif.fNumber = exifData?.FNumber || null; - newExif.focalLength = exifData?.FocalLength ? parseFloat(exifData.FocalLength) : null; - newExif.iso = exifData?.ISO || null; - newExif.latitude = exifData?.GPSLatitude || null; - newExif.longitude = exifData?.GPSLongitude || null; - newExif.livePhotoCID = exifData?.MediaGroupUUID || null; + newExif.lensModel = getExifProperty('LensModel'); + newExif.fNumber = getExifProperty('FNumber'); + const focalLength = getExifProperty('FocalLength'); + newExif.focalLength = focalLength ? parseFloat(focalLength) : null; + // This is unusual - exifData.ISO should return a number, but experienced that sidecar XMP + // files MAY return an array of numbers instead. + const iso = getExifProperty('ISO'); + newExif.iso = Array.isArray(iso) ? iso[0] : iso || null; + newExif.latitude = getExifProperty('GPSLatitude'); + newExif.longitude = getExifProperty('GPSLongitude'); + newExif.livePhotoCID = getExifProperty('MediaGroupUUID'); if (newExif.livePhotoCID && !asset.livePhotoVideoId) { const motionAsset = await this.assetCore.findLivePhotoMatch({ @@ -220,7 +245,7 @@ export class MetadataExtractionProcessor { } } - const exifData = await exiftool.read(asset.originalPath).catch((error: any) => { + const exifData = await exiftool.read(asset.sidecarPath || asset.originalPath).catch((error: any) => { this.logger.warn( `The exifData parsing failed due to ${error} for asset ${asset.id} at ${asset.originalPath}`, error?.stack, @@ -345,3 +370,83 @@ export class MetadataExtractionProcessor { return Duration.fromObject({ seconds: videoDurationInSecond }).toFormat('hh:mm:ss.SSS'); } } + +@Processor(QueueName.SIDECAR) +export class SidecarProcessor { + private logger = new Logger(SidecarProcessor.name); + private assetCore: AssetCore; + + constructor( + @Inject(IAssetRepository) private assetRepository: IAssetRepository, + @Inject(IJobRepository) private jobRepository: IJobRepository, + ) { + this.assetCore = new AssetCore(assetRepository, jobRepository); + } + + @Process(JobName.QUEUE_SIDECAR) + async handleQueueSidecar(job: Job) { + try { + const { force } = job.data; + const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { + return force + ? this.assetRepository.getWith(pagination, WithProperty.SIDECAR) + : this.assetRepository.getWithout(pagination, WithoutProperty.SIDECAR); + }); + + for await (const assets of assetPagination) { + for (const asset of assets) { + const name = force ? JobName.SIDECAR_SYNC : JobName.SIDECAR_DISCOVERY; + await this.jobRepository.queue({ name, data: { asset } }); + } + } + } catch (error: any) { + this.logger.error(`Unable to queue sidecar scanning`, error?.stack); + } + } + + @Process(JobName.SIDECAR_SYNC) + async handleSidecarSync(job: Job) { + const { asset } = job.data; + if (!asset.isVisible) { + return; + } + + try { + const name = asset.type === AssetType.VIDEO ? JobName.EXTRACT_VIDEO_METADATA : JobName.EXIF_EXTRACTION; + await this.jobRepository.queue({ name, data: { asset } }); + } catch (error: any) { + this.logger.error(`Unable to queue metadata extraction`, error?.stack); + } + } + + @Process(JobName.SIDECAR_DISCOVERY) + async handleSidecarDiscovery(job: Job) { + let { asset } = job.data; + if (!asset.isVisible) { + return; + } + + if (asset.sidecarPath) { + return; + } + + try { + await fs.promises.access(`${asset.originalPath}.xmp`, fs.constants.W_OK); + + try { + asset = await this.assetCore.save({ id: asset.id, sidecarPath: `${asset.originalPath}.xmp` }); + // TODO: optimize to only queue assets with recent xmp changes + const name = asset.type === AssetType.VIDEO ? JobName.EXTRACT_VIDEO_METADATA : JobName.EXIF_EXTRACTION; + await this.jobRepository.queue({ name, data: { asset } }); + } catch (error: any) { + this.logger.error(`Unable to sync sidecar`, error?.stack); + } + } catch (error: any) { + if (error.code == 'EACCES') { + this.logger.error(`Unable to queue metadata extraction, file is not writable`, error?.stack); + } + + return; + } + } +} diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 10704b9bd3f7b..9d971801c5262 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -4913,6 +4913,9 @@ }, "recognize-faces-queue": { "$ref": "#/components/schemas/JobStatusDto" + }, + "sidecar-queue": { + "$ref": "#/components/schemas/JobStatusDto" } }, "required": [ @@ -4924,7 +4927,8 @@ "storage-template-migration-queue", "background-task-queue", "search-queue", - "recognize-faces-queue" + "recognize-faces-queue", + "sidecar-queue" ] }, "JobName": { @@ -4938,7 +4942,8 @@ "clip-encoding-queue", "background-task-queue", "storage-template-migration-queue", - "search-queue" + "search-queue", + "sidecar-queue" ] }, "JobCommand": { @@ -5708,6 +5713,10 @@ "type": "string", "format": "binary" }, + "sidecarData": { + "type": "string", + "format": "binary" + }, "deviceAssetId": { "type": "string" }, diff --git a/server/libs/domain/src/asset/asset.repository.ts b/server/libs/domain/src/asset/asset.repository.ts index bc3f8456350f1..929701879f936 100644 --- a/server/libs/domain/src/asset/asset.repository.ts +++ b/server/libs/domain/src/asset/asset.repository.ts @@ -30,6 +30,11 @@ export enum WithoutProperty { CLIP_ENCODING = 'clip-embedding', OBJECT_TAGS = 'object-tags', FACES = 'faces', + SIDECAR = 'sidecar', +} + +export enum WithProperty { + SIDECAR = 'sidecar', } export const IAssetRepository = 'IAssetRepository'; @@ -37,6 +42,7 @@ export const IAssetRepository = 'IAssetRepository'; export interface IAssetRepository { getByIds(ids: string[]): Promise; getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated; + getWith(pagination: PaginationOptions, property: WithProperty): Paginated; getFirstAssetForAlbumId(albumId: string): Promise; deleteAll(ownerId: string): Promise; getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated; diff --git a/server/libs/domain/src/job/job.constants.ts b/server/libs/domain/src/job/job.constants.ts index acccb6cc09bea..9edd1df96c9c3 100644 --- a/server/libs/domain/src/job/job.constants.ts +++ b/server/libs/domain/src/job/job.constants.ts @@ -8,6 +8,7 @@ export enum QueueName { BACKGROUND_TASK = 'background-task-queue', STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration-queue', SEARCH = 'search-queue', + SIDECAR = 'sidecar-queue', } export enum JobCommand { @@ -72,6 +73,11 @@ export enum JobName { // clip QUEUE_ENCODE_CLIP = 'queue-clip-encode', ENCODE_CLIP = 'clip-encode', + + // XMP sidecars + QUEUE_SIDECAR = 'queue-sidecar', + SIDECAR_DISCOVERY = 'sidecar-discovery', + SIDECAR_SYNC = 'sidecar-sync', } export const JOBS_ASSET_PAGINATION_SIZE = 1000; diff --git a/server/libs/domain/src/job/job.repository.ts b/server/libs/domain/src/job/job.repository.ts index 06c6b6eda7903..65cd9b77d101f 100644 --- a/server/libs/domain/src/job/job.repository.ts +++ b/server/libs/domain/src/job/job.repository.ts @@ -50,6 +50,11 @@ export type JobItem = | { name: JobName.EXIF_EXTRACTION; data: IAssetJob } | { name: JobName.EXTRACT_VIDEO_METADATA; data: IAssetJob } + // Sidecar Scanning + | { name: JobName.QUEUE_SIDECAR; data: IBaseJob } + | { name: JobName.SIDECAR_DISCOVERY; data: IAssetJob } + | { name: JobName.SIDECAR_SYNC; data: IAssetJob } + // Object Tagging | { name: JobName.QUEUE_OBJECT_TAGGING; data: IBaseJob } | { name: JobName.DETECT_OBJECTS; data: IAssetJob } diff --git a/server/libs/domain/src/job/job.service.spec.ts b/server/libs/domain/src/job/job.service.spec.ts index b71e808160cb5..1c329f34e7878 100644 --- a/server/libs/domain/src/job/job.service.spec.ts +++ b/server/libs/domain/src/job/job.service.spec.ts @@ -67,6 +67,7 @@ describe(JobService.name, () => { 'thumbnail-generation-queue': expectedJobStatus, 'video-conversion-queue': expectedJobStatus, 'recognize-faces-queue': expectedJobStatus, + 'sidecar-queue': expectedJobStatus, }); }); }); diff --git a/server/libs/domain/src/job/job.service.ts b/server/libs/domain/src/job/job.service.ts index a552c01c0de4b..5244e2e62a187 100644 --- a/server/libs/domain/src/job/job.service.ts +++ b/server/libs/domain/src/job/job.service.ts @@ -76,6 +76,9 @@ export class JobService { case QueueName.METADATA_EXTRACTION: return this.jobRepository.queue({ name: JobName.QUEUE_METADATA_EXTRACTION, data: { force } }); + case QueueName.SIDECAR: + return this.jobRepository.queue({ name: JobName.QUEUE_SIDECAR, data: { force } }); + case QueueName.THUMBNAIL_GENERATION: return this.jobRepository.queue({ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force } }); diff --git a/server/libs/domain/src/job/response-dto/all-job-status-response.dto.ts b/server/libs/domain/src/job/response-dto/all-job-status-response.dto.ts index a988bbfcc28c7..6003c15c676c2 100644 --- a/server/libs/domain/src/job/response-dto/all-job-status-response.dto.ts +++ b/server/libs/domain/src/job/response-dto/all-job-status-response.dto.ts @@ -56,4 +56,7 @@ export class AllJobStatusResponseDto implements Record @ApiProperty({ type: JobStatusDto }) [QueueName.RECOGNIZE_FACES]!: JobStatusDto; + + @ApiProperty({ type: JobStatusDto }) + [QueueName.SIDECAR]!: JobStatusDto; } diff --git a/server/libs/domain/src/storage-template/storage-template.service.ts b/server/libs/domain/src/storage-template/storage-template.service.ts index 986f879e51c39..e62ade1d3cd6a 100644 --- a/server/libs/domain/src/storage-template/storage-template.service.ts +++ b/server/libs/domain/src/storage-template/storage-template.service.ts @@ -82,14 +82,32 @@ export class StorageTemplateService { if (asset.originalPath !== destination) { const source = asset.originalPath; + let sidecarMoved = false; try { await this.storageRepository.moveFile(asset.originalPath, destination); + + let sidecarDestination; try { - await this.assetRepository.save({ id: asset.id, originalPath: destination }); + if (asset.sidecarPath) { + sidecarDestination = `${destination}.xmp`; + await this.storageRepository.moveFile(asset.sidecarPath, sidecarDestination); + sidecarMoved = true; + } + + await this.assetRepository.save({ id: asset.id, originalPath: destination, sidecarPath: sidecarDestination }); asset.originalPath = destination; + asset.sidecarPath = sidecarDestination || null; } catch (error: any) { this.logger.warn('Unable to save new originalPath to database, undoing move', error?.stack); + + // Either sidecar move failed or the save failed. Eithr way, move media back await this.storageRepository.moveFile(destination, source); + + if (asset.sidecarPath && sidecarDestination && sidecarMoved) { + // If the sidecar was moved, that means the saved failed. So move both the sidecar and the + // media back into their original positions + await this.storageRepository.moveFile(sidecarDestination, asset.sidecarPath); + } } } catch (error: any) { this.logger.error(`Problem applying storage template`, error?.stack, { id: asset.id, source, destination }); diff --git a/server/libs/domain/test/asset.repository.mock.ts b/server/libs/domain/test/asset.repository.mock.ts index 62965245dfff4..71d31ab5ee680 100644 --- a/server/libs/domain/test/asset.repository.mock.ts +++ b/server/libs/domain/test/asset.repository.mock.ts @@ -4,6 +4,7 @@ export const newAssetRepositoryMock = (): jest.Mocked => { return { getByIds: jest.fn(), getWithout: jest.fn(), + getWith: jest.fn(), getFirstAssetForAlbumId: jest.fn(), getAll: jest.fn().mockResolvedValue({ items: [], diff --git a/server/libs/domain/test/fixtures.ts b/server/libs/domain/test/fixtures.ts index 664347da0cbc6..2ab37507cba47 100644 --- a/server/libs/domain/test/fixtures.ts +++ b/server/libs/domain/test/fixtures.ts @@ -163,6 +163,7 @@ export const assetEntityStub = { tags: [], sharedLinks: [], faces: [], + sidecarPath: null, }), image: Object.freeze({ id: 'asset-id', @@ -191,6 +192,7 @@ export const assetEntityStub = { sharedLinks: [], originalFileName: 'asset-id.ext', faces: [], + sidecarPath: null, }), video: Object.freeze({ id: 'asset-id', @@ -219,6 +221,7 @@ export const assetEntityStub = { tags: [], sharedLinks: [], faces: [], + sidecarPath: null, }), livePhotoMotionAsset: Object.freeze({ id: 'live-photo-motion-asset', @@ -252,6 +255,7 @@ export const assetEntityStub = { checksum: Buffer.from('file hash', 'utf8'), originalPath: '/original/path.ext', resizePath: '/uploads/user-id/thumbs/path.ext', + sidecarPath: null, type: AssetType.IMAGE, webpPath: null, encodedVideoPath: null, @@ -719,6 +723,7 @@ export const sharedLinkStub = { tags: [], sharedLinks: [], faces: [], + sidecarPath: null, }, ], }, diff --git a/server/libs/infra/src/entities/asset.entity.ts b/server/libs/infra/src/entities/asset.entity.ts index 3e6356e2c6b06..58ea69565e5a6 100644 --- a/server/libs/infra/src/entities/asset.entity.ts +++ b/server/libs/infra/src/entities/asset.entity.ts @@ -95,6 +95,9 @@ export class AssetEntity { @Column({ type: 'varchar' }) originalFileName!: string; + @Column({ type: 'varchar', nullable: true }) + sidecarPath!: string | null; + @OneToOne(() => ExifEntity, (exifEntity) => exifEntity.asset) exifInfo?: ExifEntity; diff --git a/server/libs/infra/src/migrations/1684273840676-AddSidecarFile.ts b/server/libs/infra/src/migrations/1684273840676-AddSidecarFile.ts new file mode 100644 index 0000000000000..46c4b5d375480 --- /dev/null +++ b/server/libs/infra/src/migrations/1684273840676-AddSidecarFile.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddSidecarFile1684273840676 implements MigrationInterface { + name = 'AddSidecarFile1684273840676' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "assets" ADD "sidecarPath" character varying`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "sidecarPath"`); + } + +} diff --git a/server/libs/infra/src/repositories/asset.repository.ts b/server/libs/infra/src/repositories/asset.repository.ts index 22697fd11406a..2653a56711396 100644 --- a/server/libs/infra/src/repositories/asset.repository.ts +++ b/server/libs/infra/src/repositories/asset.repository.ts @@ -7,6 +7,7 @@ import { Paginated, PaginationOptions, WithoutProperty, + WithProperty, } from '@app/domain'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; @@ -161,6 +162,13 @@ export class AssetRepository implements IAssetRepository { }; break; + case WithoutProperty.SIDECAR: + where = [ + { sidecarPath: IsNull(), isVisible: true }, + { sidecarPath: '', isVisible: true }, + ]; + break; + default: throw new Error(`Invalid getWithout property: ${property}`); } @@ -175,6 +183,27 @@ export class AssetRepository implements IAssetRepository { }); } + getWith(pagination: PaginationOptions, property: WithProperty): Paginated { + let where: FindOptionsWhere | FindOptionsWhere[] = {}; + + switch (property) { + case WithProperty.SIDECAR: + where = [{ sidecarPath: Not(IsNull()), isVisible: true }]; + break; + + default: + throw new Error(`Invalid getWith property: ${property}`); + } + + return paginate(this.repository, pagination, { + where, + order: { + // Ensures correct order when paginating + createdAt: 'ASC', + }, + }); + } + getFirstAssetForAlbumId(albumId: string): Promise { return this.repository.findOne({ where: { albums: { id: albumId } }, diff --git a/server/libs/infra/src/repositories/job.repository.ts b/server/libs/infra/src/repositories/job.repository.ts index f1da9f05c90c1..c0ea801c38b9e 100644 --- a/server/libs/infra/src/repositories/job.repository.ts +++ b/server/libs/infra/src/repositories/job.repository.ts @@ -15,6 +15,7 @@ export class JobRepository implements IJobRepository { [QueueName.VIDEO_CONVERSION]: this.videoTranscode, [QueueName.BACKGROUND_TASK]: this.backgroundTask, [QueueName.SEARCH]: this.searchIndex, + [QueueName.SIDECAR]: this.sidecar, }; constructor( @@ -27,6 +28,7 @@ export class JobRepository implements IJobRepository { @InjectQueue(QueueName.THUMBNAIL_GENERATION) private generateThumbnail: Queue, @InjectQueue(QueueName.VIDEO_CONVERSION) private videoTranscode: Queue, @InjectQueue(QueueName.SEARCH) private searchIndex: Queue, + @InjectQueue(QueueName.SIDECAR) private sidecar: Queue, ) {} async getQueueStatus(name: QueueName): Promise { @@ -83,6 +85,12 @@ export class JobRepository implements IJobRepository { await this.metadataExtraction.add(item.name, item.data); break; + case JobName.QUEUE_SIDECAR: + case JobName.SIDECAR_DISCOVERY: + case JobName.SIDECAR_SYNC: + await this.sidecar.add(item.name, item.data); + break; + case JobName.QUEUE_RECOGNIZE_FACES: case JobName.RECOGNIZE_FACES: await this.recognizeFaces.add(item.name, item.data); diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 37d1ce449145a..17e681ebafd0c 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -345,6 +345,12 @@ export interface AllJobStatusResponseDto { * @memberof AllJobStatusResponseDto */ 'recognize-faces-queue': JobStatusDto; + /** + * + * @type {JobStatusDto} + * @memberof AllJobStatusResponseDto + */ + 'sidecar-queue': JobStatusDto; } /** * @@ -1441,7 +1447,8 @@ export const JobName = { ClipEncodingQueue: 'clip-encoding-queue', BackgroundTaskQueue: 'background-task-queue', StorageTemplateMigrationQueue: 'storage-template-migration-queue', - SearchQueue: 'search-queue' + SearchQueue: 'search-queue', + SidecarQueue: 'sidecar-queue' } as const; export type JobName = typeof JobName[keyof typeof JobName]; @@ -5314,13 +5321,14 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration * @param {string} fileExtension * @param {string} [key] * @param {File} [livePhotoData] + * @param {File} [sidecarData] * @param {boolean} [isArchived] * @param {boolean} [isVisible] * @param {string} [duration] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - uploadFile: async (assetType: AssetTypeEnum, assetData: File, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, isFavorite: boolean, fileExtension: string, key?: string, livePhotoData?: File, isArchived?: boolean, isVisible?: boolean, duration?: string, options: AxiosRequestConfig = {}): Promise => { + uploadFile: async (assetType: AssetTypeEnum, assetData: File, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, isFavorite: boolean, fileExtension: string, key?: string, livePhotoData?: File, sidecarData?: File, isArchived?: boolean, isVisible?: boolean, duration?: string, options: AxiosRequestConfig = {}): Promise => { // verify required parameter 'assetType' is not null or undefined assertParamExists('uploadFile', 'assetType', assetType) // verify required parameter 'assetData' is not null or undefined @@ -5376,6 +5384,10 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration localVarFormParams.append('livePhotoData', livePhotoData as any); } + if (sidecarData !== undefined) { + localVarFormParams.append('sidecarData', sidecarData as any); + } + if (deviceAssetId !== undefined) { localVarFormParams.append('deviceAssetId', deviceAssetId as any); } @@ -5709,14 +5721,15 @@ export const AssetApiFp = function(configuration?: Configuration) { * @param {string} fileExtension * @param {string} [key] * @param {File} [livePhotoData] + * @param {File} [sidecarData] * @param {boolean} [isArchived] * @param {boolean} [isVisible] * @param {string} [duration] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async uploadFile(assetType: AssetTypeEnum, assetData: File, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, isFavorite: boolean, fileExtension: string, key?: string, livePhotoData?: File, isArchived?: boolean, isVisible?: boolean, duration?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.uploadFile(assetType, assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, fileExtension, key, livePhotoData, isArchived, isVisible, duration, options); + async uploadFile(assetType: AssetTypeEnum, assetData: File, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, isFavorite: boolean, fileExtension: string, key?: string, livePhotoData?: File, sidecarData?: File, isArchived?: boolean, isVisible?: boolean, duration?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.uploadFile(assetType, assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, fileExtension, key, livePhotoData, sidecarData, isArchived, isVisible, duration, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, } @@ -5978,14 +5991,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath * @param {string} fileExtension * @param {string} [key] * @param {File} [livePhotoData] + * @param {File} [sidecarData] * @param {boolean} [isArchived] * @param {boolean} [isVisible] * @param {string} [duration] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - uploadFile(assetType: AssetTypeEnum, assetData: File, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, isFavorite: boolean, fileExtension: string, key?: string, livePhotoData?: File, isArchived?: boolean, isVisible?: boolean, duration?: string, options?: any): AxiosPromise { - return localVarFp.uploadFile(assetType, assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, fileExtension, key, livePhotoData, isArchived, isVisible, duration, options).then((request) => request(axios, basePath)); + uploadFile(assetType: AssetTypeEnum, assetData: File, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, isFavorite: boolean, fileExtension: string, key?: string, livePhotoData?: File, sidecarData?: File, isArchived?: boolean, isVisible?: boolean, duration?: string, options?: any): AxiosPromise { + return localVarFp.uploadFile(assetType, assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, fileExtension, key, livePhotoData, sidecarData, isArchived, isVisible, duration, options).then((request) => request(axios, basePath)); }, }; }; @@ -6296,6 +6310,7 @@ export class AssetApi extends BaseAPI { * @param {string} fileExtension * @param {string} [key] * @param {File} [livePhotoData] + * @param {File} [sidecarData] * @param {boolean} [isArchived] * @param {boolean} [isVisible] * @param {string} [duration] @@ -6303,8 +6318,8 @@ export class AssetApi extends BaseAPI { * @throws {RequiredError} * @memberof AssetApi */ - public uploadFile(assetType: AssetTypeEnum, assetData: File, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, isFavorite: boolean, fileExtension: string, key?: string, livePhotoData?: File, isArchived?: boolean, isVisible?: boolean, duration?: string, options?: AxiosRequestConfig) { - return AssetApiFp(this.configuration).uploadFile(assetType, assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, fileExtension, key, livePhotoData, isArchived, isVisible, duration, options).then((request) => request(this.axios, this.basePath)); + public uploadFile(assetType: AssetTypeEnum, assetData: File, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, isFavorite: boolean, fileExtension: string, key?: string, livePhotoData?: File, sidecarData?: File, isArchived?: boolean, isVisible?: boolean, duration?: string, options?: AxiosRequestConfig) { + return AssetApiFp(this.configuration).uploadFile(assetType, assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, fileExtension, key, livePhotoData, sidecarData, isArchived, isVisible, duration, options).then((request) => request(this.axios, this.basePath)); } } diff --git a/web/src/lib/components/admin-page/jobs/job-tile.svelte b/web/src/lib/components/admin-page/jobs/job-tile.svelte index 1b139dcda132f..d7bea7ddef9b9 100644 --- a/web/src/lib/components/admin-page/jobs/job-tile.svelte +++ b/web/src/lib/components/admin-page/jobs/job-tile.svelte @@ -20,6 +20,9 @@ export let allowForceCommand = true; export let icon: typeof Icon; + export let allText: string; + export let missingText: string; + $: waitingCount = jobCounts.waiting + jobCounts.paused + jobCounts.delayed; $: isIdle = !queueStatus.isActive && !queueStatus.isPaused; @@ -117,13 +120,15 @@ color="gray" on:click={() => dispatch('command', { command: JobCommand.Start, force: true })} > - ALL + + {allText} dispatch('command', { command: JobCommand.Start, force: false })} > - MISSING + + {missingText} {:else} - {#each jobDetailsArray as [jobName, { title, subtitle, allowForceCommand, icon, component, handleCommand: handleCommandOverride }]} + {#each jobDetailsArray as [jobName, { title, subtitle, allText, missingText, allowForceCommand, icon, component, handleCommand: handleCommandOverride }]} {@const { jobCounts, queueStatus } = jobs[jobName]}