feat(server): xmp sidecar metadata (#2466)

* initial commit for XMP sidecar support

* Added support for 'missing' metadata files to include those without sidecar files, now detects sidecar files in the filesystem for media already ingested but the sidecar was created afterwards

* didn't mean to commit default log level during testing

* new sidecar logic for video metadata as well

* Added xml mimetype for sidecars only

* don't need capture group for this regex

* wrong default value reverted

* simplified the move here - keep it in the same try catch since the outcome is to move the media back anyway

* simplified setter logic

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>

* simplified logic per suggestions

* sidecar is now its own queue with a discover and sync, updated UI for the new job queueing

* queue a sidecar job for every asset based on discovery or sync, though the logic is almost identical aside from linking the sidecar

* now queue sidecar jobs for each assset, though logic is mostly the same between discovery and sync

* simplified logic of filename extraction and asset instantiation

* not sure how that got deleted..

* updated code per suggestions and comments in the PR

* stat was not being used, removed the variable set

* better type checking, using in-scope variables for exif getter instead of passing in every time

* removed commented out test

* ran and resolved all lints, formats, checks, and tests

* resolved suggested change in PR

* made getExifProperty more dynamic with multiple possible args for fallbacks, fixed typo, used generic in function  for better type checking

* better error handling and moving files back to positions on move or save failure

* regenerated api

* format fixes

* Added XMP documentation

* documentation typo

* Merged in main

* missed merge conflict

* more changes due to a merge

* Resolving conflicts

* added icon for sidecar jobs

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
Alex Phillips 2023-05-24 21:59:30 -04:00 committed by GitHub
parent 1b54c4f8e7
commit 7c1dae918d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 371 additions and 48 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@ -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.
<img src={require('./img/xmp-sidecars.png').default} title='XMP sidecars' />
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.
<img src={require('./img/sidecar-jobs.png').default} title='Sidecar Administrator Jobs' />

View File

@ -17,6 +17,7 @@ Name | Type | Description | Notes
**backgroundTaskQueue** | [**JobStatusDto**](JobStatusDto.md) | | **backgroundTaskQueue** | [**JobStatusDto**](JobStatusDto.md) | |
**searchQueue** | [**JobStatusDto**](JobStatusDto.md) | | **searchQueue** | [**JobStatusDto**](JobStatusDto.md) | |
**recognizeFacesQueue** | [**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) [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@ -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) [[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** # **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 fileExtension = fileExtension_example; // String |
final key = key_example; // String | final key = key_example; // String |
final livePhotoData = BINARY_DATA_HERE; // MultipartFile | final livePhotoData = BINARY_DATA_HERE; // MultipartFile |
final sidecarData = BINARY_DATA_HERE; // MultipartFile |
final isArchived = true; // bool | final isArchived = true; // bool |
final isVisible = true; // bool | final isVisible = true; // bool |
final duration = duration_example; // String | final duration = duration_example; // String |
try { 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); print(result);
} catch (e) { } catch (e) {
print('Exception when calling AssetApi->uploadFile: $e\n'); print('Exception when calling AssetApi->uploadFile: $e\n');
@ -1502,6 +1503,7 @@ Name | Type | Description | Notes
**fileExtension** | **String**| | **fileExtension** | **String**| |
**key** | **String**| | [optional] **key** | **String**| | [optional]
**livePhotoData** | **MultipartFile**| | [optional] **livePhotoData** | **MultipartFile**| | [optional]
**sidecarData** | **MultipartFile**| | [optional]
**isArchived** | **bool**| | [optional] **isArchived** | **bool**| | [optional]
**isVisible** | **bool**| | [optional] **isVisible** | **bool**| | [optional]
**duration** | **String**| | [optional] **duration** | **String**| | [optional]

View File

@ -1396,12 +1396,14 @@ class AssetApi {
/// ///
/// * [MultipartFile] livePhotoData: /// * [MultipartFile] livePhotoData:
/// ///
/// * [MultipartFile] sidecarData:
///
/// * [bool] isArchived: /// * [bool] isArchived:
/// ///
/// * [bool] isVisible: /// * [bool] isVisible:
/// ///
/// * [String] duration: /// * [String] duration:
Future<Response> 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<Response> 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 // ignore: prefer_const_declarations
final path = r'/asset/upload'; final path = r'/asset/upload';
@ -1434,6 +1436,11 @@ class AssetApi {
mp.fields[r'livePhotoData'] = livePhotoData.field; mp.fields[r'livePhotoData'] = livePhotoData.field;
mp.files.add(livePhotoData); mp.files.add(livePhotoData);
} }
if (sidecarData != null) {
hasFields = true;
mp.fields[r'sidecarData'] = sidecarData.field;
mp.files.add(sidecarData);
}
if (deviceAssetId != null) { if (deviceAssetId != null) {
hasFields = true; hasFields = true;
mp.fields[r'deviceAssetId'] = parameterToString(deviceAssetId); mp.fields[r'deviceAssetId'] = parameterToString(deviceAssetId);
@ -1507,13 +1514,15 @@ class AssetApi {
/// ///
/// * [MultipartFile] livePhotoData: /// * [MultipartFile] livePhotoData:
/// ///
/// * [MultipartFile] sidecarData:
///
/// * [bool] isArchived: /// * [bool] isArchived:
/// ///
/// * [bool] isVisible: /// * [bool] isVisible:
/// ///
/// * [String] duration: /// * [String] duration:
Future<AssetFileUploadResponseDto?> 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<AssetFileUploadResponseDto?> 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, isArchived: isArchived, isVisible: isVisible, duration: duration, ); 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) { if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); throw ApiException(response.statusCode, await _decodeBodyBytes(response));
} }

View File

@ -22,6 +22,7 @@ class AllJobStatusResponseDto {
required this.backgroundTaskQueue, required this.backgroundTaskQueue,
required this.searchQueue, required this.searchQueue,
required this.recognizeFacesQueue, required this.recognizeFacesQueue,
required this.sidecarQueue,
}); });
JobStatusDto thumbnailGenerationQueue; JobStatusDto thumbnailGenerationQueue;
@ -42,6 +43,8 @@ class AllJobStatusResponseDto {
JobStatusDto recognizeFacesQueue; JobStatusDto recognizeFacesQueue;
JobStatusDto sidecarQueue;
@override @override
bool operator ==(Object other) => identical(this, other) || other is AllJobStatusResponseDto && bool operator ==(Object other) => identical(this, other) || other is AllJobStatusResponseDto &&
other.thumbnailGenerationQueue == thumbnailGenerationQueue && other.thumbnailGenerationQueue == thumbnailGenerationQueue &&
@ -52,7 +55,8 @@ class AllJobStatusResponseDto {
other.storageTemplateMigrationQueue == storageTemplateMigrationQueue && other.storageTemplateMigrationQueue == storageTemplateMigrationQueue &&
other.backgroundTaskQueue == backgroundTaskQueue && other.backgroundTaskQueue == backgroundTaskQueue &&
other.searchQueue == searchQueue && other.searchQueue == searchQueue &&
other.recognizeFacesQueue == recognizeFacesQueue; other.recognizeFacesQueue == recognizeFacesQueue &&
other.sidecarQueue == sidecarQueue;
@override @override
int get hashCode => int get hashCode =>
@ -65,10 +69,11 @@ class AllJobStatusResponseDto {
(storageTemplateMigrationQueue.hashCode) + (storageTemplateMigrationQueue.hashCode) +
(backgroundTaskQueue.hashCode) + (backgroundTaskQueue.hashCode) +
(searchQueue.hashCode) + (searchQueue.hashCode) +
(recognizeFacesQueue.hashCode); (recognizeFacesQueue.hashCode) +
(sidecarQueue.hashCode);
@override @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<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@ -81,6 +86,7 @@ class AllJobStatusResponseDto {
json[r'background-task-queue'] = this.backgroundTaskQueue; json[r'background-task-queue'] = this.backgroundTaskQueue;
json[r'search-queue'] = this.searchQueue; json[r'search-queue'] = this.searchQueue;
json[r'recognize-faces-queue'] = this.recognizeFacesQueue; json[r'recognize-faces-queue'] = this.recognizeFacesQueue;
json[r'sidecar-queue'] = this.sidecarQueue;
return json; return json;
} }
@ -112,6 +118,7 @@ class AllJobStatusResponseDto {
backgroundTaskQueue: JobStatusDto.fromJson(json[r'background-task-queue'])!, backgroundTaskQueue: JobStatusDto.fromJson(json[r'background-task-queue'])!,
searchQueue: JobStatusDto.fromJson(json[r'search-queue'])!, searchQueue: JobStatusDto.fromJson(json[r'search-queue'])!,
recognizeFacesQueue: JobStatusDto.fromJson(json[r'recognize-faces-queue'])!, recognizeFacesQueue: JobStatusDto.fromJson(json[r'recognize-faces-queue'])!,
sidecarQueue: JobStatusDto.fromJson(json[r'sidecar-queue'])!,
); );
} }
return null; return null;
@ -168,6 +175,7 @@ class AllJobStatusResponseDto {
'background-task-queue', 'background-task-queue',
'search-queue', 'search-queue',
'recognize-faces-queue', 'recognize-faces-queue',
'sidecar-queue',
}; };
} }

View File

@ -32,6 +32,7 @@ class JobName {
static const backgroundTaskQueue = JobName._(r'background-task-queue'); static const backgroundTaskQueue = JobName._(r'background-task-queue');
static const storageTemplateMigrationQueue = JobName._(r'storage-template-migration-queue'); static const storageTemplateMigrationQueue = JobName._(r'storage-template-migration-queue');
static const searchQueue = JobName._(r'search-queue'); static const searchQueue = JobName._(r'search-queue');
static const sidecarQueue = JobName._(r'sidecar-queue');
/// List of all possible values in this [enum][JobName]. /// List of all possible values in this [enum][JobName].
static const values = <JobName>[ static const values = <JobName>[
@ -44,6 +45,7 @@ class JobName {
backgroundTaskQueue, backgroundTaskQueue,
storageTemplateMigrationQueue, storageTemplateMigrationQueue,
searchQueue, searchQueue,
sidecarQueue,
]; ];
static JobName? fromJson(dynamic value) => JobNameTypeTransformer().decode(value); static JobName? fromJson(dynamic value) => JobNameTypeTransformer().decode(value);
@ -91,6 +93,7 @@ class JobNameTypeTransformer {
case r'background-task-queue': return JobName.backgroundTaskQueue; case r'background-task-queue': return JobName.backgroundTaskQueue;
case r'storage-template-migration-queue': return JobName.storageTemplateMigrationQueue; case r'storage-template-migration-queue': return JobName.storageTemplateMigrationQueue;
case r'search-queue': return JobName.searchQueue; case r'search-queue': return JobName.searchQueue;
case r'sidecar-queue': return JobName.sidecarQueue;
default: default:
if (!allowNull) { if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data'); throw ArgumentError('Unknown enum value to decode: $data');

View File

@ -61,6 +61,11 @@ void main() {
// TODO // TODO
}); });
// JobStatusDto sidecarQueue
test('to test the property `sidecarQueue`', () async {
// TODO
});
}); });

View File

@ -158,7 +158,7 @@ void main() {
// TODO // TODO
}); });
//Future<AssetFileUploadResponseDto> 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<AssetFileUploadResponseDto> 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 { test('test uploadFile', () async {
// TODO // TODO
}); });

View File

@ -78,6 +78,7 @@ export class AssetController {
[ [
{ name: 'assetData', maxCount: 1 }, { name: 'assetData', maxCount: 1 },
{ name: 'livePhotoData', maxCount: 1 }, { name: 'livePhotoData', maxCount: 1 },
{ name: 'sidecarData', maxCount: 1 },
], ],
assetUploadOption, assetUploadOption,
), ),
@ -90,18 +91,24 @@ export class AssetController {
async uploadFile( async uploadFile(
@GetAuthUser() authUser: AuthUserDto, @GetAuthUser() authUser: AuthUserDto,
@UploadedFiles(new ParseFilePipe({ validators: [new FileNotEmptyValidator(['assetData'])] })) @UploadedFiles(new ParseFilePipe({ validators: [new FileNotEmptyValidator(['assetData'])] }))
files: { assetData: ImmichFile[]; livePhotoData?: ImmichFile[] }, files: { assetData: ImmichFile[]; livePhotoData?: ImmichFile[]; sidecarData: ImmichFile[] },
@Body(new ValidationPipe()) dto: CreateAssetDto, @Body(new ValidationPipe()) dto: CreateAssetDto,
@Response({ passthrough: true }) res: Res, @Response({ passthrough: true }) res: Res,
): Promise<AssetFileUploadResponseDto> { ): Promise<AssetFileUploadResponseDto> {
const file = mapToUploadFile(files.assetData[0]); const file = mapToUploadFile(files.assetData[0]);
const _livePhotoFile = files.livePhotoData?.[0]; const _livePhotoFile = files.livePhotoData?.[0];
const _sidecarFile = files.sidecarData?.[0];
let livePhotoFile; let livePhotoFile;
if (_livePhotoFile) { if (_livePhotoFile) {
livePhotoFile = mapToUploadFile(_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) { if (responseDto.duplicate) {
res.status(200); res.status(200);
} }

View File

@ -12,6 +12,7 @@ export class AssetCore {
dto: CreateAssetDto, dto: CreateAssetDto,
file: UploadFile, file: UploadFile,
livePhotoAssetId?: string, livePhotoAssetId?: string,
sidecarFile?: UploadFile,
): Promise<AssetEntity> { ): Promise<AssetEntity> {
const asset = await this.repository.create({ const asset = await this.repository.create({
owner: { id: authUser.id } as UserEntity, owner: { id: authUser.id } as UserEntity,
@ -39,6 +40,7 @@ export class AssetCore {
sharedLinks: [], sharedLinks: [],
originalFileName: parse(file.originalName).name, originalFileName: parse(file.originalName).name,
faces: [], faces: [],
sidecarPath: sidecarFile?.originalPath || null,
}); });
await this.jobRepository.queue({ name: JobName.ASSET_UPLOADED, data: { asset } }); await this.jobRepository.queue({ name: JobName.ASSET_UPLOADED, data: { asset } });

View File

@ -305,7 +305,7 @@ describe('AssetService', () => {
expect(jobMock.queue).toHaveBeenCalledWith({ expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.DELETE_FILES, 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(); expect(storageMock.moveFile).not.toHaveBeenCalled();
}); });
@ -413,10 +413,12 @@ describe('AssetService', () => {
undefined, undefined,
undefined, undefined,
undefined, undefined,
undefined,
'fake_path/asset_1.mp4', 'fake_path/asset_1.mp4',
undefined, undefined,
undefined, undefined,
undefined, undefined,
undefined,
], ],
}, },
}); });
@ -462,10 +464,12 @@ describe('AssetService', () => {
'web-path-1', 'web-path-1',
'resize-path-1', 'resize-path-1',
undefined, undefined,
undefined,
'original-path-2', 'original-path-2',
'web-path-2', 'web-path-2',
'resize-path-2', 'resize-path-2',
'encoded-video-path-2', 'encoded-video-path-2',
undefined,
], ],
}, },
}, },

View File

@ -106,6 +106,7 @@ export class AssetService {
dto: CreateAssetDto, dto: CreateAssetDto,
file: UploadFile, file: UploadFile,
livePhotoFile?: UploadFile, livePhotoFile?: UploadFile,
sidecarFile?: UploadFile,
): Promise<AssetFileUploadResponseDto> { ): Promise<AssetFileUploadResponseDto> {
if (livePhotoFile) { if (livePhotoFile) {
livePhotoFile = { livePhotoFile = {
@ -122,14 +123,14 @@ export class AssetService {
livePhotoAsset = await this.assetCore.create(authUser, livePhotoDto, livePhotoFile); 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 }; return { id: asset.id, duplicate: false };
} catch (error: any) { } catch (error: any) {
// clean up files // clean up files
await this.jobRepository.queue({ await this.jobRepository.queue({
name: JobName.DELETE_FILES, name: JobName.DELETE_FILES,
data: { files: [file.originalPath, livePhotoFile?.originalPath] }, data: { files: [file.originalPath, livePhotoFile?.originalPath, sidecarFile?.originalPath] },
}); });
// handle duplicates with a success response // 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] } }); await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [id] } });
result.push({ id, status: DeleteAssetStatusEnum.SUCCESS }); 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 // TODO refactor this to use cascades
if (asset.livePhotoVideoId && !ids.includes(asset.livePhotoVideoId)) { if (asset.livePhotoVideoId && !ids.includes(asset.livePhotoVideoId)) {

View File

@ -45,6 +45,9 @@ export class CreateAssetDto {
@ApiProperty({ type: 'string', format: 'binary' }) @ApiProperty({ type: 'string', format: 'binary' })
livePhotoData?: any; livePhotoData?: any;
@ApiProperty({ type: 'string', format: 'binary' })
sidecarData?: any;
} }
export interface UploadFile { export interface UploadFile {

View File

@ -60,6 +60,11 @@ function fileFilter(req: AuthRequest, file: any, cb: any) {
) { ) {
cb(null, true); cb(null, true);
} else { } 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}`); logger.error(`Unsupported file type ${extname(file.originalname)} file MIME type ${file.mimetype}`);
cb(new BadRequestException(`Unsupported file type ${extname(file.originalname)}`), false); 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)); return cb(null, sanitize(livePhotoFileName));
} }
if (file.fieldname === 'sidecarData') {
const sidecarFileName = `${fileNameUUID}.xmp`;
return cb(null, sanitize(sidecarFileName));
}
const fileName = `${fileNameUUID}${req.body['fileExtension']}`; const fileName = `${fileNameUUID}${req.body['fileExtension']}`;
return cb(null, sanitize(fileName)); return cb(null, sanitize(fileName));
} }

View File

@ -13,7 +13,7 @@ import {
ThumbnailGeneratorProcessor, ThumbnailGeneratorProcessor,
VideoTranscodeProcessor, VideoTranscodeProcessor,
} from './processors'; } from './processors';
import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor'; import { MetadataExtractionProcessor, SidecarProcessor } from './processors/metadata-extraction.processor';
@Module({ @Module({
imports: [ imports: [
@ -31,6 +31,7 @@ import { MetadataExtractionProcessor } from './processors/metadata-extraction.pr
BackgroundTaskProcessor, BackgroundTaskProcessor,
SearchIndexProcessor, SearchIndexProcessor,
FacialRecognitionProcessor, FacialRecognitionProcessor,
SidecarProcessor,
], ],
}) })
export class MicroservicesModule {} export class MicroservicesModule {}

View File

@ -10,6 +10,7 @@ import {
QueueName, QueueName,
usePagination, usePagination,
WithoutProperty, WithoutProperty,
WithProperty,
} from '@app/domain'; } from '@app/domain';
import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities'; import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
import { Process, Processor } from '@nestjs/bull'; import { Process, Processor } from '@nestjs/bull';
@ -98,13 +99,22 @@ export class MetadataExtractionProcessor {
let asset = job.data.asset; let asset = job.data.asset;
try { try {
const exifData = await exiftool.read<ImmichTags>(asset.originalPath).catch((error: any) => { const mediaExifData = await exiftool.read<ImmichTags>(asset.originalPath).catch((error: any) => {
this.logger.warn( this.logger.warn(
`The exifData parsing failed due to ${error} for asset ${asset.id} at ${asset.originalPath}`, `The exifData parsing failed due to ${error} for asset ${asset.id} at ${asset.originalPath}`,
error?.stack, error?.stack,
); );
return null; return null;
}); });
const sidecarExifData = asset.sidecarPath
? await exiftool.read<ImmichTags>(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) => { const exifToDate = (exifDate: string | ExifDateTime | undefined) => {
if (!exifDate) return null; if (!exifDate) return null;
@ -126,31 +136,46 @@ export class MetadataExtractionProcessor {
return exifDate.zone ?? null; return exifDate.zone ?? null;
}; };
const timeZone = exifTimeZone(exifData?.DateTimeOriginal ?? exifData?.CreateDate ?? asset.fileCreatedAt); const getExifProperty = <T extends keyof ImmichTags>(...properties: T[]): any | null => {
const fileCreatedAt = exifToDate(exifData?.DateTimeOriginal ?? exifData?.CreateDate ?? asset.fileCreatedAt); for (const property of properties) {
const fileModifiedAt = exifToDate(exifData?.ModifyDate ?? asset.fileModifiedAt); 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 fileStats = fs.statSync(asset.originalPath);
const fileSizeInBytes = fileStats.size; const fileSizeInBytes = fileStats.size;
const newExif = new ExifEntity(); const newExif = new ExifEntity();
newExif.assetId = asset.id; newExif.assetId = asset.id;
newExif.fileSizeInByte = fileSizeInBytes; newExif.fileSizeInByte = fileSizeInBytes;
newExif.make = exifData?.Make || null; newExif.make = getExifProperty('Make');
newExif.model = exifData?.Model || null; newExif.model = getExifProperty('Model');
newExif.exifImageHeight = exifData?.ExifImageHeight || exifData?.ImageHeight || null; newExif.exifImageHeight = getExifProperty('ExifImageHeight', 'ImageHeight');
newExif.exifImageWidth = exifData?.ExifImageWidth || exifData?.ImageWidth || null; newExif.exifImageWidth = getExifProperty('ExifImageWidth', 'ImageWidth');
newExif.exposureTime = exifData?.ExposureTime || null; newExif.exposureTime = getExifProperty('ExposureTime');
newExif.orientation = exifData?.Orientation?.toString() || null; newExif.orientation = getExifProperty('Orientation')?.toString();
newExif.dateTimeOriginal = fileCreatedAt; newExif.dateTimeOriginal = fileCreatedAt;
newExif.modifyDate = fileModifiedAt; newExif.modifyDate = fileModifiedAt;
newExif.timeZone = timeZone; newExif.timeZone = timeZone;
newExif.lensModel = exifData?.LensModel || null; newExif.lensModel = getExifProperty('LensModel');
newExif.fNumber = exifData?.FNumber || null; newExif.fNumber = getExifProperty('FNumber');
newExif.focalLength = exifData?.FocalLength ? parseFloat(exifData.FocalLength) : null; const focalLength = getExifProperty('FocalLength');
newExif.iso = exifData?.ISO || null; newExif.focalLength = focalLength ? parseFloat(focalLength) : null;
newExif.latitude = exifData?.GPSLatitude || null; // This is unusual - exifData.ISO should return a number, but experienced that sidecar XMP
newExif.longitude = exifData?.GPSLongitude || null; // files MAY return an array of numbers instead.
newExif.livePhotoCID = exifData?.MediaGroupUUID || null; 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) { if (newExif.livePhotoCID && !asset.livePhotoVideoId) {
const motionAsset = await this.assetCore.findLivePhotoMatch({ const motionAsset = await this.assetCore.findLivePhotoMatch({
@ -220,7 +245,7 @@ export class MetadataExtractionProcessor {
} }
} }
const exifData = await exiftool.read<ImmichTags>(asset.originalPath).catch((error: any) => { const exifData = await exiftool.read<ImmichTags>(asset.sidecarPath || asset.originalPath).catch((error: any) => {
this.logger.warn( this.logger.warn(
`The exifData parsing failed due to ${error} for asset ${asset.id} at ${asset.originalPath}`, `The exifData parsing failed due to ${error} for asset ${asset.id} at ${asset.originalPath}`,
error?.stack, error?.stack,
@ -345,3 +370,83 @@ export class MetadataExtractionProcessor {
return Duration.fromObject({ seconds: videoDurationInSecond }).toFormat('hh:mm:ss.SSS'); 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<IBaseJob>) {
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<IAssetJob>) {
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<IAssetJob>) {
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;
}
}
}

View File

@ -4913,6 +4913,9 @@
}, },
"recognize-faces-queue": { "recognize-faces-queue": {
"$ref": "#/components/schemas/JobStatusDto" "$ref": "#/components/schemas/JobStatusDto"
},
"sidecar-queue": {
"$ref": "#/components/schemas/JobStatusDto"
} }
}, },
"required": [ "required": [
@ -4924,7 +4927,8 @@
"storage-template-migration-queue", "storage-template-migration-queue",
"background-task-queue", "background-task-queue",
"search-queue", "search-queue",
"recognize-faces-queue" "recognize-faces-queue",
"sidecar-queue"
] ]
}, },
"JobName": { "JobName": {
@ -4938,7 +4942,8 @@
"clip-encoding-queue", "clip-encoding-queue",
"background-task-queue", "background-task-queue",
"storage-template-migration-queue", "storage-template-migration-queue",
"search-queue" "search-queue",
"sidecar-queue"
] ]
}, },
"JobCommand": { "JobCommand": {
@ -5708,6 +5713,10 @@
"type": "string", "type": "string",
"format": "binary" "format": "binary"
}, },
"sidecarData": {
"type": "string",
"format": "binary"
},
"deviceAssetId": { "deviceAssetId": {
"type": "string" "type": "string"
}, },

View File

@ -30,6 +30,11 @@ export enum WithoutProperty {
CLIP_ENCODING = 'clip-embedding', CLIP_ENCODING = 'clip-embedding',
OBJECT_TAGS = 'object-tags', OBJECT_TAGS = 'object-tags',
FACES = 'faces', FACES = 'faces',
SIDECAR = 'sidecar',
}
export enum WithProperty {
SIDECAR = 'sidecar',
} }
export const IAssetRepository = 'IAssetRepository'; export const IAssetRepository = 'IAssetRepository';
@ -37,6 +42,7 @@ export const IAssetRepository = 'IAssetRepository';
export interface IAssetRepository { export interface IAssetRepository {
getByIds(ids: string[]): Promise<AssetEntity[]>; getByIds(ids: string[]): Promise<AssetEntity[]>;
getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated<AssetEntity>; getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated<AssetEntity>;
getWith(pagination: PaginationOptions, property: WithProperty): Paginated<AssetEntity>;
getFirstAssetForAlbumId(albumId: string): Promise<AssetEntity | null>; getFirstAssetForAlbumId(albumId: string): Promise<AssetEntity | null>;
deleteAll(ownerId: string): Promise<void>; deleteAll(ownerId: string): Promise<void>;
getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated<AssetEntity>; getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated<AssetEntity>;

View File

@ -8,6 +8,7 @@ export enum QueueName {
BACKGROUND_TASK = 'background-task-queue', BACKGROUND_TASK = 'background-task-queue',
STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration-queue', STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration-queue',
SEARCH = 'search-queue', SEARCH = 'search-queue',
SIDECAR = 'sidecar-queue',
} }
export enum JobCommand { export enum JobCommand {
@ -72,6 +73,11 @@ export enum JobName {
// clip // clip
QUEUE_ENCODE_CLIP = 'queue-clip-encode', QUEUE_ENCODE_CLIP = 'queue-clip-encode',
ENCODE_CLIP = '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; export const JOBS_ASSET_PAGINATION_SIZE = 1000;

View File

@ -50,6 +50,11 @@ export type JobItem =
| { name: JobName.EXIF_EXTRACTION; data: IAssetJob } | { name: JobName.EXIF_EXTRACTION; data: IAssetJob }
| { name: JobName.EXTRACT_VIDEO_METADATA; 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 // Object Tagging
| { name: JobName.QUEUE_OBJECT_TAGGING; data: IBaseJob } | { name: JobName.QUEUE_OBJECT_TAGGING; data: IBaseJob }
| { name: JobName.DETECT_OBJECTS; data: IAssetJob } | { name: JobName.DETECT_OBJECTS; data: IAssetJob }

View File

@ -67,6 +67,7 @@ describe(JobService.name, () => {
'thumbnail-generation-queue': expectedJobStatus, 'thumbnail-generation-queue': expectedJobStatus,
'video-conversion-queue': expectedJobStatus, 'video-conversion-queue': expectedJobStatus,
'recognize-faces-queue': expectedJobStatus, 'recognize-faces-queue': expectedJobStatus,
'sidecar-queue': expectedJobStatus,
}); });
}); });
}); });

View File

@ -76,6 +76,9 @@ export class JobService {
case QueueName.METADATA_EXTRACTION: case QueueName.METADATA_EXTRACTION:
return this.jobRepository.queue({ name: JobName.QUEUE_METADATA_EXTRACTION, data: { force } }); 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: case QueueName.THUMBNAIL_GENERATION:
return this.jobRepository.queue({ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force } }); return this.jobRepository.queue({ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force } });

View File

@ -56,4 +56,7 @@ export class AllJobStatusResponseDto implements Record<QueueName, JobStatusDto>
@ApiProperty({ type: JobStatusDto }) @ApiProperty({ type: JobStatusDto })
[QueueName.RECOGNIZE_FACES]!: JobStatusDto; [QueueName.RECOGNIZE_FACES]!: JobStatusDto;
@ApiProperty({ type: JobStatusDto })
[QueueName.SIDECAR]!: JobStatusDto;
} }

View File

@ -82,14 +82,32 @@ export class StorageTemplateService {
if (asset.originalPath !== destination) { if (asset.originalPath !== destination) {
const source = asset.originalPath; const source = asset.originalPath;
let sidecarMoved = false;
try { try {
await this.storageRepository.moveFile(asset.originalPath, destination); await this.storageRepository.moveFile(asset.originalPath, destination);
let sidecarDestination;
try { 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.originalPath = destination;
asset.sidecarPath = sidecarDestination || null;
} catch (error: any) { } catch (error: any) {
this.logger.warn('Unable to save new originalPath to database, undoing move', error?.stack); 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); 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) { } catch (error: any) {
this.logger.error(`Problem applying storage template`, error?.stack, { id: asset.id, source, destination }); this.logger.error(`Problem applying storage template`, error?.stack, { id: asset.id, source, destination });

View File

@ -4,6 +4,7 @@ export const newAssetRepositoryMock = (): jest.Mocked<IAssetRepository> => {
return { return {
getByIds: jest.fn(), getByIds: jest.fn(),
getWithout: jest.fn(), getWithout: jest.fn(),
getWith: jest.fn(),
getFirstAssetForAlbumId: jest.fn(), getFirstAssetForAlbumId: jest.fn(),
getAll: jest.fn().mockResolvedValue({ getAll: jest.fn().mockResolvedValue({
items: [], items: [],

View File

@ -163,6 +163,7 @@ export const assetEntityStub = {
tags: [], tags: [],
sharedLinks: [], sharedLinks: [],
faces: [], faces: [],
sidecarPath: null,
}), }),
image: Object.freeze<AssetEntity>({ image: Object.freeze<AssetEntity>({
id: 'asset-id', id: 'asset-id',
@ -191,6 +192,7 @@ export const assetEntityStub = {
sharedLinks: [], sharedLinks: [],
originalFileName: 'asset-id.ext', originalFileName: 'asset-id.ext',
faces: [], faces: [],
sidecarPath: null,
}), }),
video: Object.freeze<AssetEntity>({ video: Object.freeze<AssetEntity>({
id: 'asset-id', id: 'asset-id',
@ -219,6 +221,7 @@ export const assetEntityStub = {
tags: [], tags: [],
sharedLinks: [], sharedLinks: [],
faces: [], faces: [],
sidecarPath: null,
}), }),
livePhotoMotionAsset: Object.freeze({ livePhotoMotionAsset: Object.freeze({
id: 'live-photo-motion-asset', id: 'live-photo-motion-asset',
@ -252,6 +255,7 @@ export const assetEntityStub = {
checksum: Buffer.from('file hash', 'utf8'), checksum: Buffer.from('file hash', 'utf8'),
originalPath: '/original/path.ext', originalPath: '/original/path.ext',
resizePath: '/uploads/user-id/thumbs/path.ext', resizePath: '/uploads/user-id/thumbs/path.ext',
sidecarPath: null,
type: AssetType.IMAGE, type: AssetType.IMAGE,
webpPath: null, webpPath: null,
encodedVideoPath: null, encodedVideoPath: null,
@ -719,6 +723,7 @@ export const sharedLinkStub = {
tags: [], tags: [],
sharedLinks: [], sharedLinks: [],
faces: [], faces: [],
sidecarPath: null,
}, },
], ],
}, },

View File

@ -95,6 +95,9 @@ export class AssetEntity {
@Column({ type: 'varchar' }) @Column({ type: 'varchar' })
originalFileName!: string; originalFileName!: string;
@Column({ type: 'varchar', nullable: true })
sidecarPath!: string | null;
@OneToOne(() => ExifEntity, (exifEntity) => exifEntity.asset) @OneToOne(() => ExifEntity, (exifEntity) => exifEntity.asset)
exifInfo?: ExifEntity; exifInfo?: ExifEntity;

View File

@ -0,0 +1,14 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddSidecarFile1684273840676 implements MigrationInterface {
name = 'AddSidecarFile1684273840676'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "assets" ADD "sidecarPath" character varying`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "sidecarPath"`);
}
}

View File

@ -7,6 +7,7 @@ import {
Paginated, Paginated,
PaginationOptions, PaginationOptions,
WithoutProperty, WithoutProperty,
WithProperty,
} from '@app/domain'; } from '@app/domain';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
@ -161,6 +162,13 @@ export class AssetRepository implements IAssetRepository {
}; };
break; break;
case WithoutProperty.SIDECAR:
where = [
{ sidecarPath: IsNull(), isVisible: true },
{ sidecarPath: '', isVisible: true },
];
break;
default: default:
throw new Error(`Invalid getWithout property: ${property}`); throw new Error(`Invalid getWithout property: ${property}`);
} }
@ -175,6 +183,27 @@ export class AssetRepository implements IAssetRepository {
}); });
} }
getWith(pagination: PaginationOptions, property: WithProperty): Paginated<AssetEntity> {
let where: FindOptionsWhere<AssetEntity> | FindOptionsWhere<AssetEntity>[] = {};
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<AssetEntity | null> { getFirstAssetForAlbumId(albumId: string): Promise<AssetEntity | null> {
return this.repository.findOne({ return this.repository.findOne({
where: { albums: { id: albumId } }, where: { albums: { id: albumId } },

View File

@ -15,6 +15,7 @@ export class JobRepository implements IJobRepository {
[QueueName.VIDEO_CONVERSION]: this.videoTranscode, [QueueName.VIDEO_CONVERSION]: this.videoTranscode,
[QueueName.BACKGROUND_TASK]: this.backgroundTask, [QueueName.BACKGROUND_TASK]: this.backgroundTask,
[QueueName.SEARCH]: this.searchIndex, [QueueName.SEARCH]: this.searchIndex,
[QueueName.SIDECAR]: this.sidecar,
}; };
constructor( constructor(
@ -27,6 +28,7 @@ export class JobRepository implements IJobRepository {
@InjectQueue(QueueName.THUMBNAIL_GENERATION) private generateThumbnail: Queue, @InjectQueue(QueueName.THUMBNAIL_GENERATION) private generateThumbnail: Queue,
@InjectQueue(QueueName.VIDEO_CONVERSION) private videoTranscode: Queue<IAssetJob | IBaseJob>, @InjectQueue(QueueName.VIDEO_CONVERSION) private videoTranscode: Queue<IAssetJob | IBaseJob>,
@InjectQueue(QueueName.SEARCH) private searchIndex: Queue, @InjectQueue(QueueName.SEARCH) private searchIndex: Queue,
@InjectQueue(QueueName.SIDECAR) private sidecar: Queue<IBaseJob>,
) {} ) {}
async getQueueStatus(name: QueueName): Promise<QueueStatus> { async getQueueStatus(name: QueueName): Promise<QueueStatus> {
@ -83,6 +85,12 @@ export class JobRepository implements IJobRepository {
await this.metadataExtraction.add(item.name, item.data); await this.metadataExtraction.add(item.name, item.data);
break; 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.QUEUE_RECOGNIZE_FACES:
case JobName.RECOGNIZE_FACES: case JobName.RECOGNIZE_FACES:
await this.recognizeFaces.add(item.name, item.data); await this.recognizeFaces.add(item.name, item.data);

View File

@ -345,6 +345,12 @@ export interface AllJobStatusResponseDto {
* @memberof AllJobStatusResponseDto * @memberof AllJobStatusResponseDto
*/ */
'recognize-faces-queue': JobStatusDto; 'recognize-faces-queue': JobStatusDto;
/**
*
* @type {JobStatusDto}
* @memberof AllJobStatusResponseDto
*/
'sidecar-queue': JobStatusDto;
} }
/** /**
* *
@ -1441,7 +1447,8 @@ export const JobName = {
ClipEncodingQueue: 'clip-encoding-queue', ClipEncodingQueue: 'clip-encoding-queue',
BackgroundTaskQueue: 'background-task-queue', BackgroundTaskQueue: 'background-task-queue',
StorageTemplateMigrationQueue: 'storage-template-migration-queue', StorageTemplateMigrationQueue: 'storage-template-migration-queue',
SearchQueue: 'search-queue' SearchQueue: 'search-queue',
SidecarQueue: 'sidecar-queue'
} as const; } as const;
export type JobName = typeof JobName[keyof typeof JobName]; export type JobName = typeof JobName[keyof typeof JobName];
@ -5314,13 +5321,14 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
* @param {string} fileExtension * @param {string} fileExtension
* @param {string} [key] * @param {string} [key]
* @param {File} [livePhotoData] * @param {File} [livePhotoData]
* @param {File} [sidecarData]
* @param {boolean} [isArchived] * @param {boolean} [isArchived]
* @param {boolean} [isVisible] * @param {boolean} [isVisible]
* @param {string} [duration] * @param {string} [duration]
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @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<RequestArgs> => { 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<RequestArgs> => {
// verify required parameter 'assetType' is not null or undefined // verify required parameter 'assetType' is not null or undefined
assertParamExists('uploadFile', 'assetType', assetType) assertParamExists('uploadFile', 'assetType', assetType)
// verify required parameter 'assetData' is not null or undefined // 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); localVarFormParams.append('livePhotoData', livePhotoData as any);
} }
if (sidecarData !== undefined) {
localVarFormParams.append('sidecarData', sidecarData as any);
}
if (deviceAssetId !== undefined) { if (deviceAssetId !== undefined) {
localVarFormParams.append('deviceAssetId', deviceAssetId as any); localVarFormParams.append('deviceAssetId', deviceAssetId as any);
} }
@ -5709,14 +5721,15 @@ export const AssetApiFp = function(configuration?: Configuration) {
* @param {string} fileExtension * @param {string} fileExtension
* @param {string} [key] * @param {string} [key]
* @param {File} [livePhotoData] * @param {File} [livePhotoData]
* @param {File} [sidecarData]
* @param {boolean} [isArchived] * @param {boolean} [isArchived]
* @param {boolean} [isVisible] * @param {boolean} [isVisible]
* @param {string} [duration] * @param {string} [duration]
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @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<AssetFileUploadResponseDto>> { 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<AssetFileUploadResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.uploadFile(assetType, assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, fileExtension, key, livePhotoData, isArchived, isVisible, duration, options); 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); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
}, },
} }
@ -5978,14 +5991,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
* @param {string} fileExtension * @param {string} fileExtension
* @param {string} [key] * @param {string} [key]
* @param {File} [livePhotoData] * @param {File} [livePhotoData]
* @param {File} [sidecarData]
* @param {boolean} [isArchived] * @param {boolean} [isArchived]
* @param {boolean} [isVisible] * @param {boolean} [isVisible]
* @param {string} [duration] * @param {string} [duration]
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @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<AssetFileUploadResponseDto> { 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<AssetFileUploadResponseDto> {
return localVarFp.uploadFile(assetType, assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, fileExtension, key, livePhotoData, isArchived, isVisible, duration, options).then((request) => request(axios, basePath)); 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} fileExtension
* @param {string} [key] * @param {string} [key]
* @param {File} [livePhotoData] * @param {File} [livePhotoData]
* @param {File} [sidecarData]
* @param {boolean} [isArchived] * @param {boolean} [isArchived]
* @param {boolean} [isVisible] * @param {boolean} [isVisible]
* @param {string} [duration] * @param {string} [duration]
@ -6303,8 +6318,8 @@ export class AssetApi extends BaseAPI {
* @throws {RequiredError} * @throws {RequiredError}
* @memberof AssetApi * @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) { 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, isArchived, isVisible, duration, options).then((request) => request(this.axios, this.basePath)); 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));
} }
} }

View File

@ -20,6 +20,9 @@
export let allowForceCommand = true; export let allowForceCommand = true;
export let icon: typeof Icon; export let icon: typeof Icon;
export let allText: string;
export let missingText: string;
$: waitingCount = jobCounts.waiting + jobCounts.paused + jobCounts.delayed; $: waitingCount = jobCounts.waiting + jobCounts.paused + jobCounts.delayed;
$: isIdle = !queueStatus.isActive && !queueStatus.isPaused; $: isIdle = !queueStatus.isActive && !queueStatus.isPaused;
@ -117,13 +120,15 @@
color="gray" color="gray"
on:click={() => dispatch('command', { command: JobCommand.Start, force: true })} on:click={() => dispatch('command', { command: JobCommand.Start, force: true })}
> >
<AllInclusive size="24" /> ALL <AllInclusive size="24" />
{allText}
</JobTileButton> </JobTileButton>
<JobTileButton <JobTileButton
color="light-gray" color="light-gray"
on:click={() => dispatch('command', { command: JobCommand.Start, force: false })} on:click={() => dispatch('command', { command: JobCommand.Start, force: false })}
> >
<SelectionSearch size="24" /> MISSING <SelectionSearch size="24" />
{missingText}
</JobTileButton> </JobTileButton>
{:else} {:else}
<JobTileButton <JobTileButton

View File

@ -11,6 +11,7 @@
import FileJpgBox from 'svelte-material-icons/FileJpgBox.svelte'; import FileJpgBox from 'svelte-material-icons/FileJpgBox.svelte';
import FolderMove from 'svelte-material-icons/FolderMove.svelte'; import FolderMove from 'svelte-material-icons/FolderMove.svelte';
import Table from 'svelte-material-icons/Table.svelte'; import Table from 'svelte-material-icons/Table.svelte';
import FileXmlBox from 'svelte-material-icons/FileXmlBox.svelte';
import TagMultiple from 'svelte-material-icons/TagMultiple.svelte'; import TagMultiple from 'svelte-material-icons/TagMultiple.svelte';
import VectorCircle from 'svelte-material-icons/VectorCircle.svelte'; import VectorCircle from 'svelte-material-icons/VectorCircle.svelte';
import Video from 'svelte-material-icons/Video.svelte'; import Video from 'svelte-material-icons/Video.svelte';
@ -23,6 +24,8 @@
interface JobDetails { interface JobDetails {
title: string; title: string;
subtitle?: string; subtitle?: string;
allText?: string;
missingText?: string;
icon: typeof Icon; icon: typeof Icon;
allowForceCommand?: boolean; allowForceCommand?: boolean;
component?: ComponentType; component?: ComponentType;
@ -56,6 +59,13 @@
title: 'Extract Metadata', title: 'Extract Metadata',
subtitle: 'Extract metadata information i.e. GPS, resolution...etc' subtitle: 'Extract metadata information i.e. GPS, resolution...etc'
}, },
[JobName.SidecarQueue]: {
title: 'Sidecar Metadata',
icon: FileXmlBox,
subtitle: 'Discover or synchronize sidecar metadata from the filesystem',
allText: 'SYNC',
missingText: 'DISCOVER'
},
[JobName.ObjectTaggingQueue]: { [JobName.ObjectTaggingQueue]: {
icon: TagMultiple, icon: TagMultiple,
title: 'Tag Objects', title: 'Tag Objects',
@ -118,12 +128,14 @@
{/if} {/if}
<div class="flex flex-col gap-7"> <div class="flex flex-col gap-7">
{#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]} {@const { jobCounts, queueStatus } = jobs[jobName]}
<JobTile <JobTile
{icon} {icon}
{title} {title}
{subtitle} {subtitle}
allText={allText || 'ALL'}
missingText={missingText || 'MISSING'}
{allowForceCommand} {allowForceCommand}
{jobCounts} {jobCounts}
{queueStatus} {queueStatus}