From 4bc2aa54519f1b98f69f6ad9bcb588ce385b2215 Mon Sep 17 00:00:00 2001
From: Jonathan Jogenfors <jonathan@jogenfors.se>
Date: Sun, 22 Dec 2024 03:50:07 +0100
Subject: [PATCH] feat(server): Handle sidecars in external libraries (#14800)

* handle sidecars in external libraries

* don't add separate source
---
 e2e/src/api/specs/library.e2e-spec.ts       | 358 ++++++++++++++++++--
 e2e/test-assets                             |   2 +-
 server/src/services/library.service.spec.ts |  55 +--
 server/src/services/library.service.ts      |  14 +-
 server/src/services/metadata.service.ts     |   9 +-
 5 files changed, 355 insertions(+), 83 deletions(-)

diff --git a/e2e/src/api/specs/library.e2e-spec.ts b/e2e/src/api/specs/library.e2e-spec.ts
index 3f910fa1e3..dde2cf79eb 100644
--- a/e2e/src/api/specs/library.e2e-spec.ts
+++ b/e2e/src/api/specs/library.e2e-spec.ts
@@ -1,5 +1,5 @@
 import { LibraryResponseDto, LoginResponseDto, getAllLibraries, scanLibrary } from '@immich/sdk';
-import { cpSync, existsSync } from 'node:fs';
+import { cpSync, existsSync, rmSync, unlinkSync } from 'node:fs';
 import { Socket } from 'socket.io-client';
 import { userDto, uuidDto } from 'src/fixtures';
 import { errorDto } from 'src/responses';
@@ -406,65 +406,93 @@ describe('/libraries', () => {
     it('should reimport a modified file', async () => {
       const library = await utils.createLibrary(admin.accessToken, {
         ownerId: admin.userId,
-        importPaths: [`${testAssetDirInternal}/temp`],
+        importPaths: [`${testAssetDirInternal}/temp/reimport`],
       });
 
-      utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`);
-      await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000);
+      utils.createImageFile(`${testAssetDir}/temp/reimport/asset.jpg`);
+      await utimes(`${testAssetDir}/temp/reimport/asset.jpg`, 447_775_200_000);
 
       await scan(admin.accessToken, library.id);
       await utils.waitForQueueFinish(admin.accessToken, 'library');
+      await utils.waitForQueueFinish(admin.accessToken, 'sidecar');
+      await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
 
-      cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/directoryA/assetB.jpg`);
-      await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_001);
+      cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/reimport/asset.jpg`);
+      await utimes(`${testAssetDir}/temp/reimport/asset.jpg`, 447_775_200_001);
 
       const { status } = await request(app)
         .post(`/libraries/${library.id}/scan`)
         .set('Authorization', `Bearer ${admin.accessToken}`)
-        .send({ refreshModifiedFiles: true });
+        .send();
       expect(status).toBe(204);
 
       await utils.waitForQueueFinish(admin.accessToken, 'library');
+      await utils.waitForQueueFinish(admin.accessToken, 'sidecar');
       await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
-      utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`);
 
       const { assets } = await utils.searchAssets(admin.accessToken, {
         libraryId: library.id,
-        model: 'NIKON D750',
       });
-      expect(assets.count).toBe(1);
+
+      expect(assets.count).toEqual(1);
+
+      const asset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
+
+      expect(asset).toEqual(
+        expect.objectContaining({
+          originalFileName: 'asset.jpg',
+          exifInfo: expect.objectContaining({
+            model: 'NIKON D750',
+          }),
+        }),
+      );
+
+      utils.removeImageFile(`${testAssetDir}/temp/reimport/asset.jpg`);
     });
 
     it('should not reimport unmodified files', async () => {
       const library = await utils.createLibrary(admin.accessToken, {
         ownerId: admin.userId,
-        importPaths: [`${testAssetDirInternal}/temp`],
+        importPaths: [`${testAssetDirInternal}/temp/reimport`],
       });
 
-      utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`);
-      await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000);
+      utils.createImageFile(`${testAssetDir}/temp/reimport/asset.jpg`);
+      await utimes(`${testAssetDir}/temp/reimport/asset.jpg`, 447_775_200_000);
 
       await scan(admin.accessToken, library.id);
       await utils.waitForQueueFinish(admin.accessToken, 'library');
 
-      cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/directoryA/assetB.jpg`);
-      await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000);
+      cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/reimport/asset.jpg`);
+      await utimes(`${testAssetDir}/temp/reimport/asset.jpg`, 447_775_200_000);
 
       const { status } = await request(app)
         .post(`/libraries/${library.id}/scan`)
         .set('Authorization', `Bearer ${admin.accessToken}`)
-        .send({ refreshModifiedFiles: true });
+        .send();
       expect(status).toBe(204);
 
       await utils.waitForQueueFinish(admin.accessToken, 'library');
+      await utils.waitForQueueFinish(admin.accessToken, 'sidecar');
       await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
-      utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`);
 
       const { assets } = await utils.searchAssets(admin.accessToken, {
         libraryId: library.id,
-        model: 'NIKON D750',
       });
-      expect(assets.count).toBe(0);
+
+      expect(assets.count).toEqual(1);
+
+      const asset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id);
+
+      expect(asset).toEqual(
+        expect.objectContaining({
+          originalFileName: 'asset.jpg',
+          exifInfo: expect.not.objectContaining({
+            model: 'NIKON D750',
+          }),
+        }),
+      );
+
+      utils.removeImageFile(`${testAssetDir}/temp/reimport/asset.jpg`);
     });
 
     it('should set an asset offline if its file is missing', async () => {
@@ -601,6 +629,298 @@ describe('/libraries', () => {
 
       expect(assets).toEqual(assetsBefore);
     });
+
+    describe('xmp metadata', async () => {
+      it('should import metadata from file.xmp', async () => {
+        const library = await utils.createLibrary(admin.accessToken, {
+          ownerId: admin.userId,
+          importPaths: [`${testAssetDirInternal}/temp/xmp`],
+        });
+
+        cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`);
+        cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`);
+
+        await scan(admin.accessToken, library.id);
+
+        await utils.waitForQueueFinish(admin.accessToken, 'library');
+        await utils.waitForQueueFinish(admin.accessToken, 'sidecar');
+        await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
+
+        const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
+
+        expect(newAssets.items).toEqual([
+          expect.objectContaining({
+            originalFileName: 'glarus.nef',
+            fileCreatedAt: '2000-09-27T12:35:33.000Z',
+          }),
+        ]);
+
+        rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
+      });
+
+      it('should import metadata from file.ext.xmp', async () => {
+        const library = await utils.createLibrary(admin.accessToken, {
+          ownerId: admin.userId,
+          importPaths: [`${testAssetDirInternal}/temp/xmp`],
+        });
+
+        cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`);
+        cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`);
+
+        await scan(admin.accessToken, library.id);
+        await utils.waitForQueueFinish(admin.accessToken, 'library');
+        await utils.waitForQueueFinish(admin.accessToken, 'sidecar');
+        await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
+
+        const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
+
+        expect(newAssets.items).toEqual([
+          expect.objectContaining({
+            originalFileName: 'glarus.nef',
+            fileCreatedAt: '2000-09-27T12:35:33.000Z',
+          }),
+        ]);
+
+        rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
+      });
+
+      it('should import metadata in file.ext.xmp before file.xmp if both exist', async () => {
+        const library = await utils.createLibrary(admin.accessToken, {
+          ownerId: admin.userId,
+          importPaths: [`${testAssetDirInternal}/temp/xmp`],
+        });
+
+        cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`);
+        cpSync(`${testAssetDir}/metadata/xmp/dates/2010.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`);
+        cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`);
+
+        await scan(admin.accessToken, library.id);
+        await utils.waitForQueueFinish(admin.accessToken, 'library');
+        await utils.waitForQueueFinish(admin.accessToken, 'sidecar');
+        await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
+
+        const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
+
+        expect(newAssets.items).toEqual([
+          expect.objectContaining({
+            originalFileName: 'glarus.nef',
+            fileCreatedAt: '2000-09-27T12:35:33.000Z',
+          }),
+        ]);
+
+        rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
+      });
+
+      it('should switch from using file.xmp to file.ext.xmp when asset refreshes', async () => {
+        const library = await utils.createLibrary(admin.accessToken, {
+          ownerId: admin.userId,
+          importPaths: [`${testAssetDirInternal}/temp/xmp`],
+        });
+
+        cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`);
+        cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`);
+        await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000);
+
+        await scan(admin.accessToken, library.id);
+        await utils.waitForQueueFinish(admin.accessToken, 'library');
+        await utils.waitForQueueFinish(admin.accessToken, 'sidecar');
+        await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
+
+        cpSync(`${testAssetDir}/metadata/xmp/dates/2010.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`);
+        unlinkSync(`${testAssetDir}/temp/xmp/glarus.xmp`);
+        await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001);
+
+        await scan(admin.accessToken, library.id);
+        await utils.waitForQueueFinish(admin.accessToken, 'library');
+        await utils.waitForQueueFinish(admin.accessToken, 'sidecar');
+        await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
+
+        const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
+
+        expect(newAssets.items).toEqual([
+          expect.objectContaining({
+            originalFileName: 'glarus.nef',
+            fileCreatedAt: '2010-09-27T12:35:33.000Z',
+          }),
+        ]);
+
+        rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
+      });
+
+      it('should switch from using file metadata to file.xmp metadata when asset refreshes', async () => {
+        const library = await utils.createLibrary(admin.accessToken, {
+          ownerId: admin.userId,
+          importPaths: [`${testAssetDirInternal}/temp/xmp`],
+        });
+
+        cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`);
+        await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000);
+
+        await scan(admin.accessToken, library.id);
+        await utils.waitForQueueFinish(admin.accessToken, 'library');
+        await utils.waitForQueueFinish(admin.accessToken, 'sidecar');
+        await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
+
+        cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`);
+        await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001);
+
+        await scan(admin.accessToken, library.id);
+        await utils.waitForQueueFinish(admin.accessToken, 'library');
+        await utils.waitForQueueFinish(admin.accessToken, 'sidecar');
+        await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
+
+        const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
+
+        expect(newAssets.items).toEqual([
+          expect.objectContaining({
+            originalFileName: 'glarus.nef',
+            fileCreatedAt: '2000-09-27T12:35:33.000Z',
+          }),
+        ]);
+
+        rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
+      });
+
+      it('should switch from using file metadata to file.xmp metadata when asset refreshes', async () => {
+        const library = await utils.createLibrary(admin.accessToken, {
+          ownerId: admin.userId,
+          importPaths: [`${testAssetDirInternal}/temp/xmp`],
+        });
+
+        cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`);
+        await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000);
+
+        await scan(admin.accessToken, library.id);
+        await utils.waitForQueueFinish(admin.accessToken, 'library');
+        await utils.waitForQueueFinish(admin.accessToken, 'sidecar');
+        await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
+
+        cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`);
+        await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001);
+
+        await scan(admin.accessToken, library.id);
+        await utils.waitForQueueFinish(admin.accessToken, 'library');
+        await utils.waitForQueueFinish(admin.accessToken, 'sidecar');
+        await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
+
+        const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
+
+        expect(newAssets.items).toEqual([
+          expect.objectContaining({
+            originalFileName: 'glarus.nef',
+            fileCreatedAt: '2000-09-27T12:35:33.000Z',
+          }),
+        ]);
+
+        rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
+      });
+
+      it('should switch from using file.ext.xmp to file.xmp when asset refreshes', async () => {
+        const library = await utils.createLibrary(admin.accessToken, {
+          ownerId: admin.userId,
+          importPaths: [`${testAssetDirInternal}/temp/xmp`],
+        });
+
+        cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`);
+        cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`);
+        await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000);
+
+        await scan(admin.accessToken, library.id);
+        await utils.waitForQueueFinish(admin.accessToken, 'library');
+        await utils.waitForQueueFinish(admin.accessToken, 'sidecar');
+        await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
+
+        cpSync(`${testAssetDir}/metadata/xmp/dates/2010.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`);
+        unlinkSync(`${testAssetDir}/temp/xmp/glarus.nef.xmp`);
+        await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001);
+
+        await scan(admin.accessToken, library.id);
+        await utils.waitForQueueFinish(admin.accessToken, 'library');
+        await utils.waitForQueueFinish(admin.accessToken, 'sidecar');
+        await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
+
+        const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
+
+        expect(newAssets.items).toEqual([
+          expect.objectContaining({
+            originalFileName: 'glarus.nef',
+            fileCreatedAt: '2010-09-27T12:35:33.000Z',
+          }),
+        ]);
+
+        rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
+      });
+
+      it('should switch from using file.ext.xmp to file metadata', async () => {
+        const library = await utils.createLibrary(admin.accessToken, {
+          ownerId: admin.userId,
+          importPaths: [`${testAssetDirInternal}/temp/xmp`],
+        });
+
+        cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`);
+        cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`);
+        await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000);
+
+        await scan(admin.accessToken, library.id);
+        await utils.waitForQueueFinish(admin.accessToken, 'library');
+        await utils.waitForQueueFinish(admin.accessToken, 'sidecar');
+        await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
+
+        unlinkSync(`${testAssetDir}/temp/xmp/glarus.nef.xmp`);
+        await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001);
+
+        await scan(admin.accessToken, library.id);
+        await utils.waitForQueueFinish(admin.accessToken, 'library');
+        await utils.waitForQueueFinish(admin.accessToken, 'sidecar');
+        await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
+
+        const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
+
+        expect(newAssets.items).toEqual([
+          expect.objectContaining({
+            originalFileName: 'glarus.nef',
+            fileCreatedAt: '2010-07-20T17:27:12.000Z',
+          }),
+        ]);
+
+        rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
+      });
+
+      it('should switch from using file.xmp to file metadata', async () => {
+        const library = await utils.createLibrary(admin.accessToken, {
+          ownerId: admin.userId,
+          importPaths: [`${testAssetDirInternal}/temp/xmp`],
+        });
+
+        cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`);
+        cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`);
+        await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000);
+
+        await scan(admin.accessToken, library.id);
+        await utils.waitForQueueFinish(admin.accessToken, 'library');
+        await utils.waitForQueueFinish(admin.accessToken, 'sidecar');
+        await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
+
+        unlinkSync(`${testAssetDir}/temp/xmp/glarus.xmp`);
+        await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001);
+
+        await scan(admin.accessToken, library.id);
+        await utils.waitForQueueFinish(admin.accessToken, 'library');
+        await utils.waitForQueueFinish(admin.accessToken, 'sidecar');
+        await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
+
+        const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
+
+        expect(newAssets.items).toEqual([
+          expect.objectContaining({
+            originalFileName: 'glarus.nef',
+            fileCreatedAt: '2010-07-20T17:27:12.000Z',
+          }),
+        ]);
+
+        rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true });
+      });
+    });
   });
 
   describe('POST /libraries/:id/validate', () => {
diff --git a/e2e/test-assets b/e2e/test-assets
index 99544a2004..9e3b964b08 160000
--- a/e2e/test-assets
+++ b/e2e/test-assets
@@ -1 +1 @@
-Subproject commit 99544a200412d553103cc7b8f1a28f339c7cffd9
+Subproject commit 9e3b964b080dca6f035b29b86e66454ae8aeda78
diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts
index 43d6662d65..9b944045ab 100644
--- a/server/src/services/library.service.spec.ts
+++ b/server/src/services/library.service.spec.ts
@@ -414,7 +414,6 @@ describe(LibraryService.name, () => {
             localDateTime: expect.any(Date),
             type: AssetType.IMAGE,
             originalFileName: 'photo.jpg',
-            sidecarPath: null,
             isExternal: true,
           },
         ],
@@ -423,57 +422,9 @@ describe(LibraryService.name, () => {
       expect(jobMock.queue.mock.calls).toEqual([
         [
           {
-            name: JobName.METADATA_EXTRACTION,
+            name: JobName.SIDECAR_DISCOVERY,
             data: {
               id: assetStub.image.id,
-              source: 'upload',
-            },
-          },
-        ],
-      ]);
-    });
-
-    it('should import a new asset with sidecar', async () => {
-      const mockLibraryJob: ILibraryFileJob = {
-        id: libraryStub.externalLibrary1.id,
-        ownerId: mockUser.id,
-        assetPath: '/data/user1/photo.jpg',
-      };
-
-      assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null);
-      assetMock.create.mockResolvedValue(assetStub.image);
-      storageMock.checkFileExists.mockResolvedValue(true);
-      libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
-
-      await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS);
-
-      expect(assetMock.create.mock.calls).toEqual([
-        [
-          {
-            ownerId: mockUser.id,
-            libraryId: libraryStub.externalLibrary1.id,
-            checksum: expect.any(Buffer),
-            originalPath: '/data/user1/photo.jpg',
-            deviceAssetId: expect.any(String),
-            deviceId: 'Library Import',
-            fileCreatedAt: expect.any(Date),
-            fileModifiedAt: expect.any(Date),
-            localDateTime: expect.any(Date),
-            type: AssetType.IMAGE,
-            originalFileName: 'photo.jpg',
-            sidecarPath: '/data/user1/photo.jpg.xmp',
-            isExternal: true,
-          },
-        ],
-      ]);
-
-      expect(jobMock.queue.mock.calls).toEqual([
-        [
-          {
-            name: JobName.METADATA_EXTRACTION,
-            data: {
-              id: assetStub.image.id,
-              source: 'upload',
             },
           },
         ],
@@ -507,7 +458,6 @@ describe(LibraryService.name, () => {
             localDateTime: expect.any(Date),
             type: AssetType.VIDEO,
             originalFileName: 'video.mp4',
-            sidecarPath: null,
             isExternal: true,
           },
         ],
@@ -516,10 +466,9 @@ describe(LibraryService.name, () => {
       expect(jobMock.queue.mock.calls).toEqual([
         [
           {
-            name: JobName.METADATA_EXTRACTION,
+            name: JobName.SIDECAR_DISCOVERY,
             data: {
               id: assetStub.image.id,
-              source: 'upload',
             },
           },
         ],
diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts
index c0d24fea9e..0deddc8941 100644
--- a/server/src/services/library.service.ts
+++ b/server/src/services/library.service.ts
@@ -396,12 +396,6 @@ export class LibraryService extends BaseService {
 
     const pathHash = this.cryptoRepository.hashSha1(`path:${assetPath}`);
 
-    // TODO: doesn't xmp replace the file extension? Will need investigation
-    let sidecarPath: string | null = null;
-    if (await this.storageRepository.checkFileExists(`${assetPath}.xmp`, R_OK)) {
-      sidecarPath = `${assetPath}.xmp`;
-    }
-
     const assetType = mimeTypes.isVideo(assetPath) ? AssetType.VIDEO : AssetType.IMAGE;
 
     const mtime = stat.mtime;
@@ -418,8 +412,6 @@ export class LibraryService extends BaseService {
       localDateTime: mtime,
       type: assetType,
       originalFileName: parse(assetPath).base,
-
-      sidecarPath,
       isExternal: true,
     });
 
@@ -431,7 +423,11 @@ export class LibraryService extends BaseService {
   async queuePostSyncJobs(asset: AssetEntity) {
     this.logger.debug(`Queueing metadata extraction for: ${asset.originalPath}`);
 
-    await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id, source: 'upload' } });
+    // We queue a sidecar discovery which, in turn, queues metadata extraction
+    await this.jobRepository.queue({
+      name: JobName.SIDECAR_DISCOVERY,
+      data: { id: asset.id },
+    });
   }
 
   async queueScan(id: string) {
diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts
index 79a7d519d6..e0566c84b7 100644
--- a/server/src/services/metadata.service.ts
+++ b/server/src/services/metadata.service.ts
@@ -698,7 +698,7 @@ export class MetadataService extends BaseService {
       return JobStatus.FAILED;
     }
 
-    if (!isSync && (!asset.isVisible || asset.sidecarPath)) {
+    if (!isSync && (!asset.isVisible || asset.sidecarPath) && !asset.isExternal) {
       return JobStatus.FAILED;
     }
 
@@ -720,6 +720,13 @@ export class MetadataService extends BaseService {
       sidecarPath = sidecarPathWithoutExt;
     }
 
+    if (asset.isExternal) {
+      if (sidecarPath !== asset.sidecarPath) {
+        await this.assetRepository.update({ id: asset.id, sidecarPath });
+      }
+      return JobStatus.SUCCESS;
+    }
+
     if (sidecarPath) {
       await this.assetRepository.update({ id: asset.id, sidecarPath });
       return JobStatus.SUCCESS;