From 7c1dae918d0b50d930596fd4b632fbad0aaa4780 Mon Sep 17 00:00:00 2001
From: Alex Phillips <ahp118@gmail.com>
Date: Wed, 24 May 2023 21:59:30 -0400
Subject: [PATCH] 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>
---
 docs/docs/features/img/sidecar-jobs.png       | Bin 0 -> 16271 bytes
 docs/docs/features/img/xmp-sidecars.png       | Bin 0 -> 8680 bytes
 docs/docs/features/xmp-sidecars.md            |  13 ++
 mobile/openapi/doc/AllJobStatusResponseDto.md |   1 +
 mobile/openapi/doc/AssetApi.md                |   6 +-
 mobile/openapi/lib/api/asset_api.dart         |  15 +-
 .../model/all_job_status_response_dto.dart    |  14 +-
 mobile/openapi/lib/model/job_name.dart        |   3 +
 .../all_job_status_response_dto_test.dart     |   5 +
 mobile/openapi/test/asset_api_test.dart       |   2 +-
 .../src/api-v1/asset/asset.controller.ts      |  11 +-
 .../immich/src/api-v1/asset/asset.core.ts     |   2 +
 .../src/api-v1/asset/asset.service.spec.ts    |   6 +-
 .../immich/src/api-v1/asset/asset.service.ts  |  13 +-
 .../src/api-v1/asset/dto/create-asset.dto.ts  |   3 +
 .../immich/src/config/asset-upload.config.ts  |  10 ++
 .../microservices/src/microservices.module.ts |   3 +-
 .../metadata-extraction.processor.ts          | 141 +++++++++++++++---
 server/immich-openapi-specs.json              |  13 +-
 .../libs/domain/src/asset/asset.repository.ts |   6 +
 server/libs/domain/src/job/job.constants.ts   |   6 +
 server/libs/domain/src/job/job.repository.ts  |   5 +
 .../libs/domain/src/job/job.service.spec.ts   |   1 +
 server/libs/domain/src/job/job.service.ts     |   3 +
 .../all-job-status-response.dto.ts            |   3 +
 .../storage-template.service.ts               |  20 ++-
 .../libs/domain/test/asset.repository.mock.ts |   1 +
 server/libs/domain/test/fixtures.ts           |   5 +
 .../libs/infra/src/entities/asset.entity.ts   |   3 +
 .../1684273840676-AddSidecarFile.ts           |  14 ++
 .../src/repositories/asset.repository.ts      |  29 ++++
 .../infra/src/repositories/job.repository.ts  |   8 +
 web/src/api/open-api/api.ts                   |  31 +++-
 .../admin-page/jobs/job-tile.svelte           |   9 +-
 .../admin-page/jobs/jobs-panel.svelte         |  14 +-
 35 files changed, 371 insertions(+), 48 deletions(-)
 create mode 100644 docs/docs/features/img/sidecar-jobs.png
 create mode 100644 docs/docs/features/img/xmp-sidecars.png
 create mode 100644 docs/docs/features/xmp-sidecars.md
 create mode 100644 server/libs/infra/src/migrations/1684273840676-AddSidecarFile.ts

diff --git a/docs/docs/features/img/sidecar-jobs.png b/docs/docs/features/img/sidecar-jobs.png
new file mode 100644
index 0000000000000000000000000000000000000000..efa8bce82bc76280fbdb5d93714e6ba157eaff4f
GIT binary patch
literal 16271
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
X-cw>N%GVLp_s>d-%8HagK7IQ?)&6a2

literal 0
HcmV?d00001

diff --git a/docs/docs/features/img/xmp-sidecars.png b/docs/docs/features/img/xmp-sidecars.png
new file mode 100644
index 0000000000000000000000000000000000000000..c19697c62bceb9e01209ea6cdcd089b8d75dfb6b
GIT binary patch
literal 8680
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

literal 0
HcmV?d00001

diff --git a/docs/docs/features/xmp-sidecars.md b/docs/docs/features/xmp-sidecars.md
new file mode 100644
index 0000000000..e118c1de5b
--- /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.
+
+<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' />
diff --git a/mobile/openapi/doc/AllJobStatusResponseDto.md b/mobile/openapi/doc/AllJobStatusResponseDto.md
index cac4b0f44b..5578b44a0e 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 fbe6d02a70..ed78743548 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 44acdb2919..1bde075f37 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<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
     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<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 {
-    final response = await uploadFileWithHttpInfo(assetType, assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, fileExtension,  key: key, livePhotoData: livePhotoData, isArchived: isArchived, isVisible: isVisible, duration: duration, );
+  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, 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 bcf1990fac..b438bf4e9f 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<String, dynamic> toJson() {
     final json = <String, dynamic>{};
@@ -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 b76e44e3ff..5226e967dd 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 = <JobName>[
@@ -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 eff589c9a8..437ed3d1da 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 af0bc44c25..91c3d613c0 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<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 {
       // 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 774a72ea9b..6e31d8fd4e 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<AssetFileUploadResponseDto> {
     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 34e014fc72..5d1a1b50b1 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<AssetEntity> {
     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 cacacc606a..0a0fa9052f 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 145cd717de..66fa091913 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<AssetFileUploadResponseDto> {
     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 cf158a7e23..582b1249aa 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 cdf4bd60bf..0a1d615130 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 76d7957964..dc421d5f17 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 47facbaefe..5cb1ca76e8 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<ImmichTags>(asset.originalPath).catch((error: any) => {
+      const mediaExifData = await exiftool.read<ImmichTags>(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<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) => {
         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 = <T extends keyof ImmichTags>(...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<ImmichTags>(asset.originalPath).catch((error: any) => {
+      const exifData = await exiftool.read<ImmichTags>(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<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;
+    }
+  }
+}
diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json
index 10704b9bd3..9d971801c5 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 bc3f845635..929701879f 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<AssetEntity[]>;
   getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated<AssetEntity>;
+  getWith(pagination: PaginationOptions, property: WithProperty): Paginated<AssetEntity>;
   getFirstAssetForAlbumId(albumId: string): Promise<AssetEntity | null>;
   deleteAll(ownerId: string): Promise<void>;
   getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated<AssetEntity>;
diff --git a/server/libs/domain/src/job/job.constants.ts b/server/libs/domain/src/job/job.constants.ts
index acccb6cc09..9edd1df96c 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 06c6b6eda7..65cd9b77d1 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 b71e808160..1c329f34e7 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 a552c01c0d..5244e2e62a 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 a988bbfcc2..6003c15c67 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<QueueName, JobStatusDto>
 
   @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 986f879e51..e62ade1d3c 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 62965245df..71d31ab5ee 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<IAssetRepository> => {
   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 664347da0c..2ab37507cb 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<AssetEntity>({
     id: 'asset-id',
@@ -191,6 +192,7 @@ export const assetEntityStub = {
     sharedLinks: [],
     originalFileName: 'asset-id.ext',
     faces: [],
+    sidecarPath: null,
   }),
   video: Object.freeze<AssetEntity>({
     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 3e6356e2c6..58ea69565e 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 0000000000..46c4b5d375
--- /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<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"`);
+    }
+
+}
diff --git a/server/libs/infra/src/repositories/asset.repository.ts b/server/libs/infra/src/repositories/asset.repository.ts
index 22697fd114..2653a56711 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<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> {
     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 f1da9f05c9..c0ea801c38 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<IAssetJob | IBaseJob>,
     @InjectQueue(QueueName.SEARCH) private searchIndex: Queue,
+    @InjectQueue(QueueName.SIDECAR) private sidecar: Queue<IBaseJob>,
   ) {}
 
   async getQueueStatus(name: QueueName): Promise<QueueStatus> {
@@ -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 37d1ce4491..17e681ebaf 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<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
             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<AssetFileUploadResponseDto>> {
-            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<AssetFileUploadResponseDto>> {
+            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<AssetFileUploadResponseDto> {
-            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<AssetFileUploadResponseDto> {
+            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 1b139dcda1..d7bea7ddef 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 })}
 			>
-				<AllInclusive size="24" /> ALL
+				<AllInclusive size="24" />
+				{allText}
 			</JobTileButton>
 			<JobTileButton
 				color="light-gray"
 				on:click={() => dispatch('command', { command: JobCommand.Start, force: false })}
 			>
-				<SelectionSearch size="24" /> MISSING
+				<SelectionSearch size="24" />
+				{missingText}
 			</JobTileButton>
 		{:else}
 			<JobTileButton
diff --git a/web/src/lib/components/admin-page/jobs/jobs-panel.svelte b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte
index 189a1e3366..abf78ac8bc 100644
--- a/web/src/lib/components/admin-page/jobs/jobs-panel.svelte
+++ b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte
@@ -11,6 +11,7 @@
 	import FileJpgBox from 'svelte-material-icons/FileJpgBox.svelte';
 	import FolderMove from 'svelte-material-icons/FolderMove.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 VectorCircle from 'svelte-material-icons/VectorCircle.svelte';
 	import Video from 'svelte-material-icons/Video.svelte';
@@ -23,6 +24,8 @@
 	interface JobDetails {
 		title: string;
 		subtitle?: string;
+		allText?: string;
+		missingText?: string;
 		icon: typeof Icon;
 		allowForceCommand?: boolean;
 		component?: ComponentType;
@@ -56,6 +59,13 @@
 			title: 'Extract Metadata',
 			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]: {
 			icon: TagMultiple,
 			title: 'Tag Objects',
@@ -118,12 +128,14 @@
 {/if}
 
 <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]}
 		<JobTile
 			{icon}
 			{title}
 			{subtitle}
+			allText={allText || 'ALL'}
+			missingText={missingText || 'MISSING'}
 			{allowForceCommand}
 			{jobCounts}
 			{queueStatus}