From 74d431f88198fe8c07276c2ac3b8890bcfa36ce7 Mon Sep 17 00:00:00 2001
From: Jason Rasmussen <jrasm91@gmail.com>
Date: Wed, 28 Feb 2024 04:21:31 -0500
Subject: [PATCH] refactor(server): format and metadata e2e (#7477)

* refactor(server): format and metadata e2e

* refactor: on upload success waiting
---
 e2e/package-lock.json                      |  62 +++
 e2e/package.json                           |   1 +
 e2e/src/api/specs/asset.e2e-spec.ts        | 417 ++++++++++++++++++---
 e2e/src/api/specs/audit.e2e-spec.ts        |   4 +-
 e2e/src/api/specs/trash.e2e-spec.ts        |   2 +-
 e2e/src/setup.ts                           |   8 +-
 e2e/src/utils.ts                           |  77 +++-
 server/e2e/jobs/specs/formats.e2e-spec.ts  | 158 --------
 server/e2e/jobs/specs/metadata.e2e-spec.ts | 102 -----
 9 files changed, 509 insertions(+), 322 deletions(-)
 delete mode 100644 server/e2e/jobs/specs/formats.e2e-spec.ts
 delete mode 100644 server/e2e/jobs/specs/metadata.e2e-spec.ts

diff --git a/e2e/package-lock.json b/e2e/package-lock.json
index 649893ec89..edf5a1a09d 100644
--- a/e2e/package-lock.json
+++ b/e2e/package-lock.json
@@ -17,6 +17,7 @@
         "@types/pg": "^8.11.0",
         "@types/supertest": "^6.0.2",
         "@vitest/coverage-v8": "^1.3.0",
+        "exiftool-vendored": "^24.5.0",
         "luxon": "^3.4.4",
         "pg": "^8.11.3",
         "socket.io-client": "^4.7.4",
@@ -594,6 +595,12 @@
         "@jridgewell/sourcemap-codec": "^1.4.14"
       }
     },
+    "node_modules/@photostructure/tz-lookup": {
+      "version": "9.0.2",
+      "resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-9.0.2.tgz",
+      "integrity": "sha512-H8+tTt7ilJNkFyb+QgPnLEGUjQzGwiMb9n7lwRZNBgSKL3VZs9AkjI1E//FcwPjNafwAH932U92+xTqJiF3Bbw==",
+      "dev": true
+    },
     "node_modules/@playwright/test": {
       "version": "1.41.2",
       "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.41.2.tgz",
@@ -1074,6 +1081,15 @@
       "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
       "dev": true
     },
+    "node_modules/batch-cluster": {
+      "version": "13.0.0",
+      "resolved": "https://registry.npmjs.org/batch-cluster/-/batch-cluster-13.0.0.tgz",
+      "integrity": "sha512-EreW0Vi8TwovhYUHBXXRA5tthuU2ynGsZFlboyMJHCCUXYa2AjgwnE3ubBOJs2xJLcuXFJbi6c/8pH5+FVj8Og==",
+      "dev": true,
+      "engines": {
+        "node": ">=14"
+      }
+    },
     "node_modules/brace-expansion": {
       "version": "1.1.11",
       "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
@@ -1391,6 +1407,43 @@
         "url": "https://github.com/sindresorhus/execa?sponsor=1"
       }
     },
+    "node_modules/exiftool-vendored": {
+      "version": "24.5.0",
+      "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-24.5.0.tgz",
+      "integrity": "sha512-uLGYfeshak3mYn2ucCsebXfNFdOpeAULlMb84wiJv+4B236n+ypgK/vr8bJgAcsIPSRJXFSz9WonvjjQYYqR3w==",
+      "dev": true,
+      "dependencies": {
+        "@photostructure/tz-lookup": "^9.0.1",
+        "@types/luxon": "^3.4.2",
+        "batch-cluster": "^13.0.0",
+        "he": "^1.2.0",
+        "luxon": "^3.4.4"
+      },
+      "optionalDependencies": {
+        "exiftool-vendored.exe": "12.76.0",
+        "exiftool-vendored.pl": "12.76.0"
+      }
+    },
+    "node_modules/exiftool-vendored.exe": {
+      "version": "12.76.0",
+      "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.76.0.tgz",
+      "integrity": "sha512-lbKPPs31qpjnhFiMRaVxJX+iNcJ+p0NrRSFLHHaX6KTsfMba6e5i6NykSvU3wMiafzUTef1Fen3XQ+8n1tjjNw==",
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/exiftool-vendored.pl": {
+      "version": "12.76.0",
+      "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.76.0.tgz",
+      "integrity": "sha512-4DxqgnvL71YziVoY27ZMgVfLAWDH3pQLljuV5+ffTnTPvz/BWeV+/bVFwRvDqCD3lkCWds0YfVcsycfJgbQ5fA==",
+      "dev": true,
+      "optional": true,
+      "os": [
+        "!win32"
+      ]
+    },
     "node_modules/fast-safe-stringify": {
       "version": "2.1.1",
       "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
@@ -1584,6 +1637,15 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/he": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
+      "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+      "dev": true,
+      "bin": {
+        "he": "bin/he"
+      }
+    },
     "node_modules/hexoid": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz",
diff --git a/e2e/package.json b/e2e/package.json
index 7bbdfd1d9d..26a1d7ef3a 100644
--- a/e2e/package.json
+++ b/e2e/package.json
@@ -21,6 +21,7 @@
     "@types/pg": "^8.11.0",
     "@types/supertest": "^6.0.2",
     "@vitest/coverage-v8": "^1.3.0",
+    "exiftool-vendored": "^24.5.0",
     "luxon": "^3.4.4",
     "pg": "^8.11.3",
     "socket.io-client": "^4.7.4",
diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts
index db1821260b..e1f4450312 100644
--- a/e2e/src/api/specs/asset.e2e-spec.ts
+++ b/e2e/src/api/specs/asset.e2e-spec.ts
@@ -1,16 +1,39 @@
 import {
   AssetFileUploadResponseDto,
   AssetResponseDto,
+  AssetTypeEnum,
   LoginResponseDto,
   SharedLinkType,
 } from '@immich/sdk';
+import { exiftool } from 'exiftool-vendored';
 import { DateTime } from 'luxon';
+import { createHash } from 'node:crypto';
+import { readFile, writeFile } from 'node:fs/promises';
+import { basename, join } from 'node:path';
 import { Socket } from 'socket.io-client';
 import { createUserDto, uuidDto } from 'src/fixtures';
 import { errorDto } from 'src/responses';
-import { apiUtils, app, dbUtils } from 'src/utils';
+import {
+  apiUtils,
+  app,
+  dbUtils,
+  tempDir,
+  testAssetDir,
+  wsUtils,
+} from 'src/utils';
 import request from 'supertest';
-import { beforeAll, describe, expect, it } from 'vitest';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+
+const locationAssetFilepath = `${testAssetDir}/metadata/gps-position/thompson-springs.jpg`;
+
+const sha1 = (bytes: Buffer) =>
+  createHash('sha1').update(bytes).digest('base64');
+
+const readTags = async (bytes: Buffer, filename: string) => {
+  const filepath = join(tempDir, filename);
+  await writeFile(filepath, bytes);
+  return exiftool.read(filepath);
+};
 
 const today = DateTime.fromObject({
   year: 2023,
@@ -24,25 +47,36 @@ describe('/asset', () => {
   let user1: LoginResponseDto;
   let user2: LoginResponseDto;
   let userStats: LoginResponseDto;
-  let asset1: AssetFileUploadResponseDto;
-  let asset2: AssetFileUploadResponseDto;
-  let asset3: AssetFileUploadResponseDto;
-  let asset4: AssetFileUploadResponseDto; // user2 asset
-  let asset5: AssetFileUploadResponseDto;
-  let asset6: AssetFileUploadResponseDto;
+  let user1Assets: AssetFileUploadResponseDto[];
+  let user2Assets: AssetFileUploadResponseDto[];
+  let assetLocation: AssetFileUploadResponseDto;
   let ws: Socket;
 
   beforeAll(async () => {
     apiUtils.setup();
     await dbUtils.reset();
     admin = await apiUtils.adminSetup({ onboarding: false });
-    [user1, user2, userStats] = await Promise.all([
+
+    [ws, user1, user2, userStats] = await Promise.all([
+      wsUtils.connect(admin.accessToken),
       apiUtils.userSetup(admin.accessToken, createUserDto.user1),
       apiUtils.userSetup(admin.accessToken, createUserDto.user2),
       apiUtils.userSetup(admin.accessToken, createUserDto.user3),
     ]);
 
-    [asset1, asset2, asset3, asset4, asset5, asset6] = await Promise.all([
+    // asset location
+    assetLocation = await apiUtils.createAsset(
+      admin.accessToken,
+      {},
+      {
+        filename: 'thompson-springs.jpg',
+        bytes: await readFile(locationAssetFilepath),
+      },
+    );
+
+    await wsUtils.waitForEvent({ event: 'upload', assetId: assetLocation.id });
+
+    user1Assets = await Promise.all([
       apiUtils.createAsset(user1.accessToken),
       apiUtils.createAsset(user1.accessToken),
       apiUtils.createAsset(
@@ -56,10 +90,13 @@ describe('/asset', () => {
         },
         { filename: 'example.mp4' },
       ),
-      apiUtils.createAsset(user2.accessToken),
       apiUtils.createAsset(user1.accessToken),
       apiUtils.createAsset(user1.accessToken),
+    ]);
 
+    user2Assets = await Promise.all([apiUtils.createAsset(user2.accessToken)]);
+
+    await Promise.all([
       // stats
       apiUtils.createAsset(userStats.accessToken),
       apiUtils.createAsset(userStats.accessToken, { isFavorite: true }),
@@ -77,7 +114,14 @@ describe('/asset', () => {
     const person1 = await apiUtils.createPerson(user1.accessToken, {
       name: 'Test Person',
     });
-    await dbUtils.createFace({ assetId: asset1.id, personId: person1.id });
+    await dbUtils.createFace({
+      assetId: user1Assets[0].id,
+      personId: person1.id,
+    });
+  }, 30_000);
+
+  afterAll(() => {
+    wsUtils.disconnect(ws);
   });
 
   describe('GET /asset/:id', () => {
@@ -99,7 +143,7 @@ describe('/asset', () => {
 
     it('should require access', async () => {
       const { status, body } = await request(app)
-        .get(`/asset/${asset4.id}`)
+        .get(`/asset/${user2Assets[0].id}`)
         .set('Authorization', `Bearer ${user1.accessToken}`);
       expect(status).toBe(400);
       expect(body).toEqual(errorDto.noPermission);
@@ -107,33 +151,33 @@ describe('/asset', () => {
 
     it('should get the asset info', async () => {
       const { status, body } = await request(app)
-        .get(`/asset/${asset1.id}`)
+        .get(`/asset/${user1Assets[0].id}`)
         .set('Authorization', `Bearer ${user1.accessToken}`);
       expect(status).toBe(200);
-      expect(body).toMatchObject({ id: asset1.id });
+      expect(body).toMatchObject({ id: user1Assets[0].id });
     });
 
     it('should work with a shared link', async () => {
       const sharedLink = await apiUtils.createSharedLink(user1.accessToken, {
         type: SharedLinkType.Individual,
-        assetIds: [asset1.id],
+        assetIds: [user1Assets[0].id],
       });
 
       const { status, body } = await request(app).get(
-        `/asset/${asset1.id}?key=${sharedLink.key}`,
+        `/asset/${user1Assets[0].id}?key=${sharedLink.key}`,
       );
       expect(status).toBe(200);
-      expect(body).toMatchObject({ id: asset1.id });
+      expect(body).toMatchObject({ id: user1Assets[0].id });
     });
 
     it('should not send people data for shared links for un-authenticated users', async () => {
       const { status, body } = await request(app)
-        .get(`/asset/${asset1.id}`)
+        .get(`/asset/${user1Assets[0].id}`)
         .set('Authorization', `Bearer ${user1.accessToken}`);
 
       expect(status).toEqual(200);
       expect(body).toMatchObject({
-        id: asset1.id,
+        id: user1Assets[0].id,
         isFavorite: false,
         people: [
           {
@@ -148,11 +192,11 @@ describe('/asset', () => {
 
       const sharedLink = await apiUtils.createSharedLink(user1.accessToken, {
         type: SharedLinkType.Individual,
-        assetIds: [asset1.id],
+        assetIds: [user1Assets[0].id],
       });
 
       const data = await request(app).get(
-        `/asset/${asset1.id}?key=${sharedLink.key}`,
+        `/asset/${user1Assets[0].id}?key=${sharedLink.key}`,
       );
       expect(data.status).toBe(200);
       expect(data.body).toMatchObject({ people: [] });
@@ -246,11 +290,11 @@ describe('/asset', () => {
       const assets: AssetResponseDto[] = body;
       expect(assets.length).toBe(1);
       expect(assets[0].ownerId).toBe(user1.userId);
-      //
-      // assets owned by user2
-      expect(assets[0].id).not.toBe(asset4.id);
+
       // assets owned by user1
-      expect([asset1.id, asset2.id, asset3.id]).toContain(assets[0].id);
+      expect([user1Assets.map(({ id }) => id)]).toContain(assets[0].id);
+      // assets owned by user2
+      expect([user1Assets.map(({ id }) => id)]).not.toContain(assets[0].id);
     });
 
     it.each(Array(10))('should return 2 random assets', async () => {
@@ -266,9 +310,9 @@ describe('/asset', () => {
       for (const asset of assets) {
         expect(asset.ownerId).toBe(user1.userId);
         // assets owned by user1
-        expect([asset1.id, asset2.id, asset3.id]).toContain(asset.id);
+        expect([user1Assets.map(({ id }) => id)]).toContain(asset.id);
         // assets owned by user2
-        expect(asset.id).not.toBe(asset4.id);
+        expect([user2Assets.map(({ id }) => id)]).not.toContain(asset.id);
       }
     });
 
@@ -280,7 +324,9 @@ describe('/asset', () => {
           .set('Authorization', `Bearer ${user2.accessToken}`);
 
         expect(status).toBe(200);
-        expect(body).toEqual([expect.objectContaining({ id: asset4.id })]);
+        expect(body).toEqual([
+          expect.objectContaining({ id: user2Assets[0].id }),
+        ]);
       },
     );
 
@@ -312,44 +358,50 @@ describe('/asset', () => {
 
     it('should require access', async () => {
       const { status, body } = await request(app)
-        .put(`/asset/${asset4.id}`)
+        .put(`/asset/${user2Assets[0].id}`)
         .set('Authorization', `Bearer ${user1.accessToken}`);
       expect(status).toBe(400);
       expect(body).toEqual(errorDto.noPermission);
     });
 
     it('should favorite an asset', async () => {
-      const before = await apiUtils.getAssetInfo(user1.accessToken, asset1.id);
+      const before = await apiUtils.getAssetInfo(
+        user1.accessToken,
+        user1Assets[0].id,
+      );
       expect(before.isFavorite).toBe(false);
 
       const { status, body } = await request(app)
-        .put(`/asset/${asset1.id}`)
+        .put(`/asset/${user1Assets[0].id}`)
         .set('Authorization', `Bearer ${user1.accessToken}`)
         .send({ isFavorite: true });
-      expect(body).toMatchObject({ id: asset1.id, isFavorite: true });
+      expect(body).toMatchObject({ id: user1Assets[0].id, isFavorite: true });
       expect(status).toEqual(200);
     });
 
     it('should archive an asset', async () => {
-      const before = await apiUtils.getAssetInfo(user1.accessToken, asset1.id);
+      const before = await apiUtils.getAssetInfo(
+        user1.accessToken,
+        user1Assets[0].id,
+      );
       expect(before.isArchived).toBe(false);
 
       const { status, body } = await request(app)
-        .put(`/asset/${asset1.id}`)
+        .put(`/asset/${user1Assets[0].id}`)
         .set('Authorization', `Bearer ${user1.accessToken}`)
         .send({ isArchived: true });
-      expect(body).toMatchObject({ id: asset1.id, isArchived: true });
+      expect(body).toMatchObject({ id: user1Assets[0].id, isArchived: true });
       expect(status).toEqual(200);
     });
 
     it('should update date time original', async () => {
       const { status, body } = await request(app)
-        .put(`/asset/${asset1.id}`)
+        .put(`/asset/${user1Assets[0].id}`)
         .set('Authorization', `Bearer ${user1.accessToken}`)
         .send({ dateTimeOriginal: '2023-11-19T18:11:00.000-07:00' });
 
       expect(body).toMatchObject({
-        id: asset1.id,
+        id: user1Assets[0].id,
         exifInfo: expect.objectContaining({
           dateTimeOriginal: '2023-11-20T01:11:00.000Z',
         }),
@@ -371,7 +423,7 @@ describe('/asset', () => {
         { latitude: 12, longitude: 181 },
       ]) {
         const { status, body } = await request(app)
-          .put(`/asset/${asset1.id}`)
+          .put(`/asset/${user1Assets[0].id}`)
           .send(test)
           .set('Authorization', `Bearer ${user1.accessToken}`);
         expect(status).toBe(400);
@@ -381,12 +433,12 @@ describe('/asset', () => {
 
     it('should update gps data', async () => {
       const { status, body } = await request(app)
-        .put(`/asset/${asset1.id}`)
+        .put(`/asset/${user1Assets[0].id}`)
         .set('Authorization', `Bearer ${user1.accessToken}`)
         .send({ latitude: 12, longitude: 12 });
 
       expect(body).toMatchObject({
-        id: asset1.id,
+        id: user1Assets[0].id,
         exifInfo: expect.objectContaining({ latitude: 12, longitude: 12 }),
       });
       expect(status).toEqual(200);
@@ -394,11 +446,11 @@ describe('/asset', () => {
 
     it('should set the description', async () => {
       const { status, body } = await request(app)
-        .put(`/asset/${asset1.id}`)
+        .put(`/asset/${user1Assets[0].id}`)
         .set('Authorization', `Bearer ${user1.accessToken}`)
         .send({ description: 'Test asset description' });
       expect(body).toMatchObject({
-        id: asset1.id,
+        id: user1Assets[0].id,
         exifInfo: expect.objectContaining({
           description: 'Test asset description',
         }),
@@ -408,12 +460,12 @@ describe('/asset', () => {
 
     it('should return tagged people', async () => {
       const { status, body } = await request(app)
-        .put(`/asset/${asset1.id}`)
+        .put(`/asset/${user1Assets[0].id}`)
         .set('Authorization', `Bearer ${user1.accessToken}`)
         .send({ isFavorite: true });
       expect(status).toEqual(200);
       expect(body).toMatchObject({
-        id: asset1.id,
+        id: user1Assets[0].id,
         isFavorite: true,
         people: [
           {
@@ -478,4 +530,279 @@ describe('/asset', () => {
       expect(after.isTrashed).toBe(true);
     });
   });
+
+  describe('POST /asset/upload', () => {
+    const tests = [
+      {
+        input: 'formats/jpg/el_torcal_rocks.jpg',
+        expected: {
+          type: AssetTypeEnum.Image,
+          originalFileName: 'el_torcal_rocks',
+          resized: true,
+          exifInfo: {
+            dateTimeOriginal: '2012-08-05T11:39:59.000Z',
+            exifImageWidth: 512,
+            exifImageHeight: 341,
+            latitude: null,
+            longitude: null,
+            focalLength: 75,
+            iso: 200,
+            fNumber: 11,
+            exposureTime: '1/160',
+            fileSizeInByte: 53_493,
+            make: 'SONY',
+            model: 'DSLR-A550',
+            orientation: null,
+            description: 'SONY DSC',
+          },
+        },
+      },
+      {
+        input: 'formats/heic/IMG_2682.heic',
+        expected: {
+          type: AssetTypeEnum.Image,
+          originalFileName: 'IMG_2682',
+          resized: true,
+          fileCreatedAt: '2019-03-21T16:04:22.348Z',
+          exifInfo: {
+            dateTimeOriginal: '2019-03-21T16:04:22.348Z',
+            exifImageWidth: 4032,
+            exifImageHeight: 3024,
+            latitude: 41.2203,
+            longitude: -96.071_625,
+            make: 'Apple',
+            model: 'iPhone 7',
+            lensModel: 'iPhone 7 back camera 3.99mm f/1.8',
+            fileSizeInByte: 880_703,
+            exposureTime: '1/887',
+            iso: 20,
+            focalLength: 3.99,
+            fNumber: 1.8,
+            timeZone: 'America/Chicago',
+          },
+        },
+      },
+      {
+        input: 'formats/png/density_plot.png',
+        expected: {
+          type: AssetTypeEnum.Image,
+          originalFileName: 'density_plot',
+          resized: true,
+          exifInfo: {
+            exifImageWidth: 800,
+            exifImageHeight: 800,
+            latitude: null,
+            longitude: null,
+            fileSizeInByte: 25_408,
+          },
+        },
+      },
+      {
+        input: 'formats/raw/Nikon/D80/glarus.nef',
+        expected: {
+          type: AssetTypeEnum.Image,
+          originalFileName: 'glarus',
+          resized: true,
+          fileCreatedAt: '2010-07-20T17:27:12.000Z',
+          exifInfo: {
+            make: 'NIKON CORPORATION',
+            model: 'NIKON D80',
+            exposureTime: '1/200',
+            fNumber: 10,
+            focalLength: 18,
+            iso: 100,
+            fileSizeInByte: 9_057_784,
+            dateTimeOriginal: '2010-07-20T17:27:12.000Z',
+            latitude: null,
+            longitude: null,
+            orientation: '1',
+          },
+        },
+      },
+      {
+        input: 'formats/raw/Nikon/D700/philadelphia.nef',
+        expected: {
+          type: AssetTypeEnum.Image,
+          originalFileName: 'philadelphia',
+          resized: true,
+          fileCreatedAt: '2016-09-22T22:10:29.060Z',
+          exifInfo: {
+            make: 'NIKON CORPORATION',
+            model: 'NIKON D700',
+            exposureTime: '1/400',
+            fNumber: 11,
+            focalLength: 85,
+            iso: 200,
+            fileSizeInByte: 15_856_335,
+            dateTimeOriginal: '2016-09-22T22:10:29.060Z',
+            latitude: null,
+            longitude: null,
+            orientation: '1',
+            timeZone: 'UTC-5',
+          },
+        },
+      },
+    ];
+
+    for (const { input, expected } of tests) {
+      it(`should generate a thumbnail for ${input}`, async () => {
+        const filepath = join(testAssetDir, input);
+        const { id, duplicate } = await apiUtils.createAsset(
+          admin.accessToken,
+          {},
+          { bytes: await readFile(filepath), filename: basename(filepath) },
+        );
+
+        expect(duplicate).toBe(false);
+
+        await wsUtils.waitForEvent({ event: 'upload', assetId: id });
+
+        const asset = await apiUtils.getAssetInfo(admin.accessToken, id);
+
+        expect(asset.exifInfo).toBeDefined();
+        expect(asset.exifInfo).toMatchObject(expected.exifInfo);
+        expect(asset).toMatchObject(expected);
+      });
+    }
+
+    it('should handle a duplicate', async () => {
+      const filepath = 'formats/jpeg/el_torcal_rocks.jpeg';
+      const { duplicate } = await apiUtils.createAsset(
+        admin.accessToken,
+        {},
+        {
+          bytes: await readFile(join(testAssetDir, filepath)),
+          filename: basename(filepath),
+        },
+      );
+
+      expect(duplicate).toBe(true);
+    });
+
+    // These hashes were created by copying the image files to a Samsung phone,
+    // exporting the video from Samsung's stock Gallery app, and hashing them locally.
+    // This ensures that immich+exiftool are extracting the videos the same way Samsung does.
+    // DO NOT assume immich+exiftool are doing things correctly and just copy whatever hash it gives
+    // into the test here.
+    const motionTests = [
+      {
+        filepath: 'formats/motionphoto/Samsung One UI 5.jpg',
+        checksum: 'fr14niqCq6N20HB8rJYEvpsUVtI=',
+      },
+      {
+        filepath: 'formats/motionphoto/Samsung One UI 6.jpg',
+        checksum: 'lT9Uviw/FFJYCjfIxAGPTjzAmmw=',
+      },
+      {
+        filepath: 'formats/motionphoto/Samsung One UI 6.heic',
+        checksum: '/ejgzywvgvzvVhUYVfvkLzFBAF0=',
+      },
+    ];
+
+    for (const { filepath, checksum } of motionTests) {
+      it(`should extract motionphoto video from ${filepath}`, async () => {
+        const response = await apiUtils.createAsset(
+          admin.accessToken,
+          {},
+          {
+            bytes: await readFile(join(testAssetDir, filepath)),
+            filename: basename(filepath),
+          },
+        );
+
+        await wsUtils.waitForEvent({ event: 'upload', assetId: response.id });
+
+        expect(response.duplicate).toBe(false);
+
+        const asset = await apiUtils.getAssetInfo(
+          admin.accessToken,
+          response.id,
+        );
+        expect(asset.livePhotoVideoId).toBeDefined();
+
+        const video = await apiUtils.getAssetInfo(
+          admin.accessToken,
+          asset.livePhotoVideoId as string,
+        );
+        expect(video.checksum).toStrictEqual(checksum);
+      });
+    }
+  });
+
+  describe('GET /asset/thumbnail/:id', () => {
+    it('should require authentication', async () => {
+      const { status, body } = await request(app).get(
+        `/asset/thumbnail/${assetLocation.id}`,
+      );
+
+      expect(status).toBe(401);
+      expect(body).toEqual(errorDto.unauthorized);
+    });
+
+    it('should not include gps data for webp thumbnails', async () => {
+      const { status, body, type } = await request(app)
+        .get(`/asset/thumbnail/${assetLocation.id}?format=WEBP`)
+        .set('Authorization', `Bearer ${admin.accessToken}`);
+
+      await wsUtils.waitForEvent({
+        event: 'upload',
+        assetId: assetLocation.id,
+      });
+
+      expect(status).toBe(200);
+      expect(body).toBeDefined();
+      expect(type).toBe('image/webp');
+
+      const exifData = await readTags(body, 'thumbnail.webp');
+      expect(exifData).not.toHaveProperty('GPSLongitude');
+      expect(exifData).not.toHaveProperty('GPSLatitude');
+    });
+
+    it('should not include gps data for jpeg thumbnails', async () => {
+      const { status, body, type } = await request(app)
+        .get(`/asset/thumbnail/${assetLocation.id}?format=JPEG`)
+        .set('Authorization', `Bearer ${admin.accessToken}`);
+
+      expect(status).toBe(200);
+      expect(body).toBeDefined();
+      expect(type).toBe('image/jpeg');
+
+      const exifData = await readTags(body, 'thumbnail.jpg');
+      expect(exifData).not.toHaveProperty('GPSLongitude');
+      expect(exifData).not.toHaveProperty('GPSLatitude');
+    });
+  });
+
+  describe('GET /asset/file/:id', () => {
+    it('should require authentication', async () => {
+      const { status, body } = await request(app).get(
+        `/asset/thumbnail/${assetLocation.id}`,
+      );
+
+      expect(status).toBe(401);
+      expect(body).toEqual(errorDto.unauthorized);
+    });
+
+    it('should download the original', async () => {
+      const { status, body, type } = await request(app)
+        .get(`/asset/file/${assetLocation.id}`)
+        .set('Authorization', `Bearer ${admin.accessToken}`);
+
+      expect(status).toBe(200);
+      expect(body).toBeDefined();
+      expect(type).toBe('image/jpeg');
+
+      const asset = await apiUtils.getAssetInfo(
+        admin.accessToken,
+        assetLocation.id,
+      );
+
+      const original = await readFile(locationAssetFilepath);
+      const originalChecksum = sha1(original);
+      const downloadChecksum = sha1(body);
+
+      expect(originalChecksum).toBe(downloadChecksum);
+      expect(downloadChecksum).toBe(asset.checksum);
+    });
+  });
 });
diff --git a/e2e/src/api/specs/audit.e2e-spec.ts b/e2e/src/api/specs/audit.e2e-spec.ts
index 073106e728..0bc8e6b173 100644
--- a/e2e/src/api/specs/audit.e2e-spec.ts
+++ b/e2e/src/api/specs/audit.e2e-spec.ts
@@ -29,14 +29,14 @@ describe('/audit', () => {
       await Promise.all([
         deleteAssets(
           { assetBulkDeleteDto: { ids: [trashedAsset.id] } },
-          { headers: asBearerAuth(admin.accessToken) }
+          { headers: asBearerAuth(admin.accessToken) },
         ),
         updateAsset(
           {
             id: archivedAsset.id,
             updateAssetDto: { isArchived: true },
           },
-          { headers: asBearerAuth(admin.accessToken) }
+          { headers: asBearerAuth(admin.accessToken) },
         ),
       ]);
 
diff --git a/e2e/src/api/specs/trash.e2e-spec.ts b/e2e/src/api/specs/trash.e2e-spec.ts
index 2de838f981..cb4a8b9dd1 100644
--- a/e2e/src/api/specs/trash.e2e-spec.ts
+++ b/e2e/src/api/specs/trash.e2e-spec.ts
@@ -44,7 +44,7 @@ describe('/trash', () => {
         .set('Authorization', `Bearer ${admin.accessToken}`);
       expect(status).toBe(204);
 
-      await wsUtils.once(ws, 'on_asset_delete');
+      await wsUtils.waitForEvent({ event: 'delete', assetId });
 
       const after = await getAllAssets(
         {},
diff --git a/e2e/src/setup.ts b/e2e/src/setup.ts
index b560a2bbb1..04e8d79ac5 100644
--- a/e2e/src/setup.ts
+++ b/e2e/src/setup.ts
@@ -2,25 +2,25 @@ import { spawn, exec } from 'child_process';
 
 export default async () => {
   let _resolve: () => unknown;
-  const promise = new Promise<void>((resolve) => (_resolve = resolve));
+  const ready = new Promise<void>((resolve) => (_resolve = resolve));
 
   const child = spawn('docker', ['compose', 'up'], { stdio: 'pipe' });
 
   child.stdout.on('data', (data) => {
     const input = data.toString();
     console.log(input);
-    if (input.includes('Immich Server is listening')) {
+    if (input.includes('Immich Microservices is listening')) {
       _resolve();
     }
   });
 
   child.stderr.on('data', (data) => console.log(data.toString()));
 
-  await promise;
+  await ready;
 
   return async () => {
     await new Promise<void>((resolve) =>
-      exec('docker compose down', () => resolve())
+      exec('docker compose down', () => resolve()),
     );
   };
 };
diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts
index 428c88b454..4261e8f67d 100644
--- a/e2e/src/utils.ts
+++ b/e2e/src/utils.ts
@@ -1,5 +1,6 @@
 import {
   AssetFileUploadResponseDto,
+  AssetResponseDto,
   CreateAlbumDto,
   CreateAssetDto,
   CreateUserDto,
@@ -19,10 +20,12 @@ import {
   updatePerson,
 } from '@immich/sdk';
 import { BrowserContext } from '@playwright/test';
-import { exec, spawn } from 'child_process';
+import { exec, spawn } from 'node:child_process';
 import { randomBytes } from 'node:crypto';
 import { access } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
 import path from 'node:path';
+import { EventEmitter } from 'node:stream';
 import { promisify } from 'node:util';
 import pg from 'pg';
 import { io, type Socket } from 'socket.io-client';
@@ -40,6 +43,7 @@ const directoryExists = (directory: string) =>
 
 // TODO move test assets into e2e/assets
 export const testAssetDir = path.resolve(`./../server/test/assets/`);
+export const tempDir = tmpdir();
 
 const serverContainerName = 'immich-e2e-server';
 const mediaDir = '/usr/src/app/upload';
@@ -47,6 +51,7 @@ const dirs = [
   `"${mediaDir}/thumbs"`,
   `"${mediaDir}/upload"`,
   `"${mediaDir}/library"`,
+  `"${mediaDir}/encoded-video"`,
 ].join(' ');
 
 if (!(await directoryExists(`${testAssetDir}/albums`))) {
@@ -177,33 +182,85 @@ export interface AdminSetupOptions {
   onboarding?: boolean;
 }
 
+export enum SocketEvent {
+  UPLOAD = 'upload',
+  DELETE = 'delete',
+}
+
+export type EventType = 'upload' | 'delete';
+export interface WaitOptions {
+  event: EventType;
+  assetId: string;
+  timeout?: number;
+}
+
+const events: Record<EventType, Set<string>> = {
+  upload: new Set<string>(),
+  delete: new Set<string>(),
+};
+
+const callbacks: Record<string, () => void> = {};
+
+const onEvent = ({ event, assetId }: { event: EventType; assetId: string }) => {
+  events[event].add(assetId);
+  const callback = callbacks[assetId];
+  if (callback) {
+    callback();
+    delete callbacks[assetId];
+  }
+};
+
 export const wsUtils = {
   connect: async (accessToken: string) => {
     const websocket = io('http://127.0.0.1:2283', {
       path: '/api/socket.io',
       transports: ['websocket'],
       extraHeaders: { Authorization: `Bearer ${accessToken}` },
-      autoConnect: false,
+      autoConnect: true,
       forceNew: true,
     });
 
     return new Promise<Socket>((resolve) => {
-      websocket.on('connect', () => resolve(websocket));
-      websocket.connect();
+      websocket
+        .on('connect', () => resolve(websocket))
+        .on('on_upload_success', (data: AssetResponseDto) =>
+          onEvent({ event: 'upload', assetId: data.id }),
+        )
+        .on('on_asset_delete', (assetId: string) =>
+          onEvent({ event: 'delete', assetId }),
+        )
+        .connect();
     });
   },
   disconnect: (ws: Socket) => {
     if (ws?.connected) {
       ws.disconnect();
     }
+
+    for (const set of Object.values(events)) {
+      set.clear();
+    }
   },
-  once: <T = any>(ws: Socket, event: string): Promise<T> => {
-    return new Promise<T>((resolve, reject) => {
-      const timeout = setTimeout(() => reject(new Error('Timeout')), 4000);
-      ws.once(event, (data: T) => {
+  waitForEvent: async ({
+    event,
+    assetId,
+    timeout: ms,
+  }: WaitOptions): Promise<void> => {
+    const set = events[event];
+    if (set.has(assetId)) {
+      return;
+    }
+
+    return new Promise<void>((resolve, reject) => {
+      const timeout = setTimeout(
+        () => reject(new Error(`Timed out waiting for ${event} event`)),
+        ms || 5000,
+      );
+
+      callbacks[assetId] = () => {
         clearTimeout(timeout);
-        resolve(data);
-      });
+        resolve();
+      };
     });
   },
 };
diff --git a/server/e2e/jobs/specs/formats.e2e-spec.ts b/server/e2e/jobs/specs/formats.e2e-spec.ts
deleted file mode 100644
index c8b14d588a..0000000000
--- a/server/e2e/jobs/specs/formats.e2e-spec.ts
+++ /dev/null
@@ -1,158 +0,0 @@
-import { LoginResponseDto } from '@app/domain';
-import { AssetType } from '@app/infra/entities';
-import { readFile } from 'node:fs/promises';
-import { basename, join } from 'node:path';
-import { IMMICH_TEST_ASSET_PATH, testApp } from '../../../src/test-utils/utils';
-import { api } from '../../client';
-
-const JPEG = {
-  type: AssetType.IMAGE,
-  originalFileName: 'el_torcal_rocks',
-  resized: true,
-  exifInfo: {
-    dateTimeOriginal: '2012-08-05T11:39:59.000Z',
-    exifImageWidth: 512,
-    exifImageHeight: 341,
-    latitude: null,
-    longitude: null,
-    focalLength: 75,
-    iso: 200,
-    fNumber: 11,
-    exposureTime: '1/160',
-    fileSizeInByte: 53_493,
-    make: 'SONY',
-    model: 'DSLR-A550',
-    orientation: null,
-    description: 'SONY DSC',
-  },
-};
-
-const tests = [
-  { input: 'formats/jpg/el_torcal_rocks.jpg', expected: JPEG },
-  { input: 'formats/jpeg/el_torcal_rocks.jpeg', expected: JPEG },
-  {
-    input: 'formats/heic/IMG_2682.heic',
-    expected: {
-      type: AssetType.IMAGE,
-      originalFileName: 'IMG_2682',
-      resized: true,
-      fileCreatedAt: '2019-03-21T16:04:22.348Z',
-      exifInfo: {
-        dateTimeOriginal: '2019-03-21T16:04:22.348Z',
-        exifImageWidth: 4032,
-        exifImageHeight: 3024,
-        latitude: 41.2203,
-        longitude: -96.071_625,
-        make: 'Apple',
-        model: 'iPhone 7',
-        lensModel: 'iPhone 7 back camera 3.99mm f/1.8',
-        fileSizeInByte: 880_703,
-        exposureTime: '1/887',
-        iso: 20,
-        focalLength: 3.99,
-        fNumber: 1.8,
-        timeZone: 'America/Chicago',
-      },
-    },
-  },
-  {
-    input: 'formats/png/density_plot.png',
-    expected: {
-      type: AssetType.IMAGE,
-      originalFileName: 'density_plot',
-      resized: true,
-      exifInfo: {
-        exifImageWidth: 800,
-        exifImageHeight: 800,
-        latitude: null,
-        longitude: null,
-        fileSizeInByte: 25_408,
-      },
-    },
-  },
-  {
-    input: 'formats/raw/Nikon/D80/glarus.nef',
-    expected: {
-      type: AssetType.IMAGE,
-      originalFileName: 'glarus',
-      resized: true,
-      fileCreatedAt: '2010-07-20T17:27:12.000Z',
-      exifInfo: {
-        make: 'NIKON CORPORATION',
-        model: 'NIKON D80',
-        exposureTime: '1/200',
-        fNumber: 10,
-        focalLength: 18,
-        iso: 100,
-        fileSizeInByte: 9_057_784,
-        dateTimeOriginal: '2010-07-20T17:27:12.000Z',
-        latitude: null,
-        longitude: null,
-        orientation: '1',
-      },
-    },
-  },
-  {
-    input: 'formats/raw/Nikon/D700/philadelphia.nef',
-    expected: {
-      type: AssetType.IMAGE,
-      originalFileName: 'philadelphia',
-      resized: true,
-      fileCreatedAt: '2016-09-22T22:10:29.060Z',
-      exifInfo: {
-        make: 'NIKON CORPORATION',
-        model: 'NIKON D700',
-        exposureTime: '1/400',
-        fNumber: 11,
-        focalLength: 85,
-        iso: 200,
-        fileSizeInByte: 15_856_335,
-        dateTimeOriginal: '2016-09-22T22:10:29.060Z',
-        latitude: null,
-        longitude: null,
-        orientation: '1',
-        timeZone: 'UTC-5',
-      },
-    },
-  },
-];
-
-describe(`Format (e2e)`, () => {
-  let server: any;
-  let admin: LoginResponseDto;
-
-  beforeAll(async () => {
-    const app = await testApp.create();
-    server = app.getHttpServer();
-  });
-
-  beforeEach(async () => {
-    await testApp.reset();
-    await api.authApi.adminSignUp(server);
-    admin = await api.authApi.adminLogin(server);
-  });
-
-  afterAll(async () => {
-    await testApp.teardown();
-  });
-
-  for (const { input, expected } of tests) {
-    it(`should generate a thumbnail for ${input}`, async () => {
-      const filepath = join(IMMICH_TEST_ASSET_PATH, input);
-      const content = await readFile(filepath);
-      await api.assetApi.upload(server, admin.accessToken, 'test-device-id', {
-        content,
-        filename: basename(filepath),
-      });
-      const assets = await api.assetApi.getAllAssets(server, admin.accessToken);
-
-      expect(assets).toHaveLength(1);
-
-      const asset = assets[0];
-
-      expect(asset.exifInfo).toBeDefined();
-      expect(asset.exifInfo).toMatchObject(expected.exifInfo);
-      expect(asset).toMatchObject(expected);
-    });
-  }
-});
diff --git a/server/e2e/jobs/specs/metadata.e2e-spec.ts b/server/e2e/jobs/specs/metadata.e2e-spec.ts
deleted file mode 100644
index 5eb75fee2d..0000000000
--- a/server/e2e/jobs/specs/metadata.e2e-spec.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-import { AssetResponseDto, LoginResponseDto } from '@app/domain';
-import { AssetController } from '@app/immich';
-import { exiftool } from 'exiftool-vendored';
-import { readFile, writeFile } from 'fs/promises';
-import {
-  IMMICH_TEST_ASSET_PATH,
-  IMMICH_TEST_ASSET_TEMP_PATH,
-  db,
-  restoreTempFolder,
-  testApp,
-} from '../../../src/test-utils/utils';
-import { api } from '../../client';
-
-describe(`${AssetController.name} (e2e)`, () => {
-  let server: any;
-  let admin: LoginResponseDto;
-
-  beforeAll(async () => {
-    server = (await testApp.create()).getHttpServer();
-  });
-
-  beforeEach(async () => {
-    await testApp.reset();
-    await restoreTempFolder();
-    await api.authApi.adminSignUp(server);
-    admin = await api.authApi.adminLogin(server);
-  });
-
-  afterAll(async () => {
-    await testApp.teardown();
-    await restoreTempFolder();
-  });
-
-  describe('should strip metadata of', () => {
-    let assetWithLocation: AssetResponseDto;
-
-    beforeEach(async () => {
-      const fileContent = await readFile(`${IMMICH_TEST_ASSET_PATH}/metadata/gps-position/thompson-springs.jpg`);
-
-      await api.assetApi.upload(server, admin.accessToken, 'test-asset-id', { content: fileContent });
-
-      const assets = await api.assetApi.getAllAssets(server, admin.accessToken);
-
-      expect(assets).toHaveLength(1);
-      assetWithLocation = assets[0];
-
-      expect(assetWithLocation).toEqual(
-        expect.objectContaining({
-          exifInfo: expect.objectContaining({ latitude: 39.115, longitude: -108.400968333333 }),
-        }),
-      );
-    });
-
-    it('small webp thumbnails', async () => {
-      const assetId = assetWithLocation.id;
-
-      const thumbnail = await api.assetApi.getWebpThumbnail(server, admin.accessToken, assetId);
-
-      await writeFile(`${IMMICH_TEST_ASSET_TEMP_PATH}/thumbnail.webp`, thumbnail);
-
-      const exifData = await exiftool.read(`${IMMICH_TEST_ASSET_TEMP_PATH}/thumbnail.webp`);
-
-      expect(exifData).not.toHaveProperty('GPSLongitude');
-      expect(exifData).not.toHaveProperty('GPSLatitude');
-    });
-
-    it('large jpeg thumbnails', async () => {
-      const assetId = assetWithLocation.id;
-
-      const thumbnail = await api.assetApi.getJpegThumbnail(server, admin.accessToken, assetId);
-
-      await writeFile(`${IMMICH_TEST_ASSET_TEMP_PATH}/thumbnail.jpg`, thumbnail);
-
-      const exifData = await exiftool.read(`${IMMICH_TEST_ASSET_TEMP_PATH}/thumbnail.jpg`);
-
-      expect(exifData).not.toHaveProperty('GPSLongitude');
-      expect(exifData).not.toHaveProperty('GPSLatitude');
-    });
-  });
-
-  describe.each([
-    // These hashes were created by copying the image files to a Samsung phone,
-    // exporting the video from Samsung's stock Gallery app, and hashing them locally.
-    // This ensures that immich+exiftool are extracting the videos the same way Samsung does.
-    // DO NOT assume immich+exiftool are doing things correctly and just copy whatever hash it gives
-    // into the test here.
-    ['Samsung One UI 5.jpg', 'fr14niqCq6N20HB8rJYEvpsUVtI='],
-    ['Samsung One UI 6.jpg', 'lT9Uviw/FFJYCjfIxAGPTjzAmmw='],
-    ['Samsung One UI 6.heic', '/ejgzywvgvzvVhUYVfvkLzFBAF0='],
-  ])('should extract motionphoto video', (file, checksum) => {
-    it(`with checksum ${checksum} from ${file}`, async () => {
-      const fileContent = await readFile(`${IMMICH_TEST_ASSET_PATH}/formats/motionphoto/${file}`);
-
-      const response = await api.assetApi.upload(server, admin.accessToken, 'test-asset-id', { content: fileContent });
-      const asset = await api.assetApi.get(server, admin.accessToken, response.id);
-      expect(asset).toHaveProperty('livePhotoVideoId');
-      const video = await api.assetApi.get(server, admin.accessToken, asset.livePhotoVideoId as string);
-
-      expect(video.checksum).toStrictEqual(checksum);
-    });
-  });
-});