diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 24e3e08623..064e3c2761 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -80,7 +80,7 @@ jobs:
         run: npm run check
         if: ${{ !cancelled() }}
 
-      - name: Run unit tests & coverage
+      - name: Run small tests & coverage
         run: npm run test:cov
         if: ${{ !cancelled() }}
 
@@ -243,6 +243,26 @@ jobs:
         run: npm run check
         if: ${{ !cancelled() }}
 
+  medium-tests-server:
+    name: Medium Tests (Server)
+    needs: pre-job
+    if: ${{ needs.pre-job.outputs.should_run_server == 'true' }}
+    runs-on: mich
+
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v4
+        with:
+          submodules: 'recursive'
+
+      - name: Production build
+        if: ${{ !cancelled() }}
+        run: docker compose -f e2e/docker-compose.yml build
+
+      - name: Run medium tests
+        if: ${{ !cancelled() }}
+        run: make test-medium
+
   e2e-tests-server-cli:
     name: End-to-End Tests (Server & CLI)
     needs: pre-job
diff --git a/Makefile b/Makefile
index 349a5c5e92..2096cf86df 100644
--- a/Makefile
+++ b/Makefile
@@ -66,6 +66,18 @@ test-e2e:
 	docker compose -f ./e2e/docker-compose.yml build
 	npm --prefix e2e run test
 	npm --prefix e2e run test:web
+test-medium:
+	docker run \
+    --rm \
+    -v ./server/src:/usr/src/app/src \
+    -v ./server/test:/usr/src/app/test \
+    -v ./server/vitest.config.medium.mjs:/usr/src/app/vitest.config.medium.mjs \
+    -v ./server/tsconfig.json:/usr/src/app/tsconfig.json \
+    -e NODE_ENV=development \
+    immich-server:latest \
+    -c "npm ci && npm run test:medium -- --run"
+test-medium-dev:
+	docker exec -it immich_server /bin/sh -c "npm run test:medium"
 
 build-all: $(foreach M,$(MODULES),build-$M) ;
 install-all: $(foreach M,$(MODULES),install-$M) ;
diff --git a/server/package-lock.json b/server/package-lock.json
index 2990f95798..5e2c7fd662 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -86,6 +86,7 @@
         "@types/node": "^20.16.10",
         "@types/nodemailer": "^6.4.14",
         "@types/picomatch": "^3.0.0",
+        "@types/pngjs": "^6.0.5",
         "@types/react": "^18.3.4",
         "@types/semver": "^7.5.8",
         "@types/supertest": "^6.0.0",
@@ -99,6 +100,7 @@
         "eslint-plugin-unicorn": "^55.0.0",
         "globals": "^15.9.0",
         "mock-fs": "^5.2.0",
+        "pngjs": "^7.0.0",
         "prettier": "^3.0.2",
         "prettier-plugin-organize-imports": "^4.0.0",
         "rimraf": "^6.0.0",
@@ -5496,6 +5498,16 @@
       "integrity": "sha512-1MRgzpzY0hOp9pW/kLRxeQhUWwil6gnrUYd3oEpeYBqp/FexhaCPv3F8LsYr47gtUU45fO2cm1dbwkSrHEo8Uw==",
       "dev": true
     },
+    "node_modules/@types/pngjs": {
+      "version": "6.0.5",
+      "resolved": "https://registry.npmjs.org/@types/pngjs/-/pngjs-6.0.5.tgz",
+      "integrity": "sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
     "node_modules/@types/prop-types": {
       "version": "15.7.12",
       "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz",
@@ -11446,6 +11458,16 @@
         "node": ">=4"
       }
     },
+    "node_modules/pngjs": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
+      "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=14.19.0"
+      }
+    },
     "node_modules/point-in-polygon-hao": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/point-in-polygon-hao/-/point-in-polygon-hao-1.1.0.tgz",
@@ -18794,6 +18816,15 @@
       "integrity": "sha512-1MRgzpzY0hOp9pW/kLRxeQhUWwil6gnrUYd3oEpeYBqp/FexhaCPv3F8LsYr47gtUU45fO2cm1dbwkSrHEo8Uw==",
       "dev": true
     },
+    "@types/pngjs": {
+      "version": "6.0.5",
+      "resolved": "https://registry.npmjs.org/@types/pngjs/-/pngjs-6.0.5.tgz",
+      "integrity": "sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==",
+      "dev": true,
+      "requires": {
+        "@types/node": "*"
+      }
+    },
     "@types/prop-types": {
       "version": "15.7.12",
       "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz",
@@ -23193,6 +23224,12 @@
       "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==",
       "dev": true
     },
+    "pngjs": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
+      "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
+      "dev": true
+    },
     "point-in-polygon-hao": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/point-in-polygon-hao/-/point-in-polygon-hao-1.1.0.tgz",
diff --git a/server/package.json b/server/package.json
index ee548d1d77..78922609d9 100644
--- a/server/package.json
+++ b/server/package.json
@@ -19,8 +19,8 @@
     "check:code": "npm run format && npm run lint && npm run check",
     "check:all": "npm run check:code && npm run test:cov",
     "test": "vitest",
-    "test:watch": "vitest --watch",
     "test:cov": "vitest --coverage",
+    "test:medium": "vitest --config vitest.config.medium.mjs",
     "typeorm": "typeorm",
     "lifecycle": "node ./dist/utils/lifecycle.js",
     "typeorm:migrations:create": "typeorm migration:create",
@@ -111,6 +111,7 @@
     "@types/node": "^20.16.10",
     "@types/nodemailer": "^6.4.14",
     "@types/picomatch": "^3.0.0",
+    "@types/pngjs": "^6.0.5",
     "@types/react": "^18.3.4",
     "@types/semver": "^7.5.8",
     "@types/supertest": "^6.0.0",
@@ -124,6 +125,7 @@
     "eslint-plugin-unicorn": "^55.0.0",
     "globals": "^15.9.0",
     "mock-fs": "^5.2.0",
+    "pngjs": "^7.0.0",
     "prettier": "^3.0.2",
     "prettier-plugin-organize-imports": "^4.0.0",
     "rimraf": "^6.0.0",
diff --git a/server/src/repositories/metadata.repository.ts b/server/src/repositories/metadata.repository.ts
index ec79814520..dc2a4cdf9b 100644
--- a/server/src/repositories/metadata.repository.ts
+++ b/server/src/repositories/metadata.repository.ts
@@ -1,12 +1,9 @@
 import { Inject, Injectable } from '@nestjs/common';
-import { InjectRepository } from '@nestjs/typeorm';
 import { DefaultReadTaskOptions, ExifTool, Tags } from 'exiftool-vendored';
 import geotz from 'geo-tz';
-import { ExifEntity } from 'src/entities/exif.entity';
 import { ILoggerRepository } from 'src/interfaces/logger.interface';
 import { IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.interface';
 import { Instrumentation } from 'src/utils/instrumentation';
-import { Repository } from 'typeorm';
 
 @Instrumentation()
 @Injectable()
@@ -25,10 +22,7 @@ export class MetadataRepository implements IMetadataRepository {
     writeArgs: ['-api', 'largefilesupport=1', '-overwrite_original'],
   });
 
-  constructor(
-    @InjectRepository(ExifEntity) private exifRepository: Repository<ExifEntity>,
-    @Inject(ILoggerRepository) private logger: ILoggerRepository,
-  ) {
+  constructor(@Inject(ILoggerRepository) private logger: ILoggerRepository) {
     this.logger.setContext(MetadataRepository.name);
   }
 
diff --git a/server/test/medium/metadata.service.spec.ts b/server/test/medium/metadata.service.spec.ts
new file mode 100644
index 0000000000..3ccce0f16e
--- /dev/null
+++ b/server/test/medium/metadata.service.spec.ts
@@ -0,0 +1,137 @@
+import { Stats } from 'node:fs';
+import { writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { AssetEntity } from 'src/entities/asset.entity';
+import { IAssetRepository } from 'src/interfaces/asset.interface';
+import { IStorageRepository } from 'src/interfaces/storage.interface';
+import { MetadataRepository } from 'src/repositories/metadata.repository';
+import { MetadataService } from 'src/services/metadata.service';
+import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
+import { newRandomImage, newTestService } from 'test/utils';
+import { Mocked } from 'vitest';
+
+const metadataRepository = new MetadataRepository(newLoggerRepositoryMock());
+
+const createTestFile = async (exifData: Record<string, any>) => {
+  const data = newRandomImage();
+  const filePath = join(tmpdir(), 'test.png');
+  await writeFile(filePath, data);
+  await metadataRepository.writeTags(filePath, exifData);
+  return { filePath };
+};
+
+type TimeZoneTest = {
+  description: string;
+  serverTimeZone?: string;
+  exifData: Record<string, any>;
+  expected: {
+    localDateTime: string;
+    dateTimeOriginal: string;
+    timeZone: string | null;
+  };
+};
+
+describe(MetadataService.name, () => {
+  let sut: MetadataService;
+
+  let assetMock: Mocked<IAssetRepository>;
+  let storageMock: Mocked<IStorageRepository>;
+
+  beforeEach(() => {
+    ({ sut, assetMock, storageMock } = newTestService(MetadataService, { metadataRepository }));
+
+    storageMock.stat.mockResolvedValue({ size: 123_456 } as Stats);
+
+    delete process.env.TZ;
+  });
+
+  it('should be defined', () => {
+    expect(sut).toBeDefined();
+  });
+
+  describe('handleMetadataExtraction', () => {
+    const timeZoneTests: TimeZoneTest[] = [
+      {
+        description: 'should handle no time zone information',
+        exifData: {
+          DateTimeOriginal: '2022:01:01 00:00:00',
+        },
+        expected: {
+          localDateTime: '2022-01-01T00:00:00.000Z',
+          dateTimeOriginal: '2022-01-01T00:00:00.000Z',
+          timeZone: null,
+        },
+      },
+      {
+        description: 'should handle no time zone information and server behind UTC',
+        serverTimeZone: 'America/Los_Angeles',
+        exifData: {
+          DateTimeOriginal: '2022:01:01 00:00:00',
+        },
+        expected: {
+          localDateTime: '2022-01-01T00:00:00.000Z',
+          dateTimeOriginal: '2022-01-01T08:00:00.000Z',
+          timeZone: null,
+        },
+      },
+      {
+        description: 'should handle no time zone information and server ahead of UTC',
+        serverTimeZone: 'Europe/Brussels',
+        exifData: {
+          DateTimeOriginal: '2022:01:01 00:00:00',
+        },
+        expected: {
+          localDateTime: '2022-01-01T00:00:00.000Z',
+          dateTimeOriginal: '2021-12-31T23:00:00.000Z',
+          timeZone: null,
+        },
+      },
+      {
+        description: 'should handle no time zone information and server ahead of UTC in the summer',
+        serverTimeZone: 'Europe/Brussels',
+        exifData: {
+          DateTimeOriginal: '2022:06:01 00:00:00',
+        },
+        expected: {
+          localDateTime: '2022-06-01T00:00:00.000Z',
+          dateTimeOriginal: '2022-05-31T22:00:00.000Z',
+          timeZone: null,
+        },
+      },
+      {
+        description: 'should handle a +13:00 time zone',
+        exifData: {
+          DateTimeOriginal: '2022:01:01 00:00:00+13:00',
+        },
+        expected: {
+          localDateTime: '2022-01-01T00:00:00.000Z',
+          dateTimeOriginal: '2021-12-31T11:00:00.000Z',
+          timeZone: 'UTC+13',
+        },
+      },
+    ];
+
+    it.each(timeZoneTests)('$description', async ({ exifData, serverTimeZone, expected }) => {
+      process.env.TZ = serverTimeZone ?? undefined;
+
+      const { filePath } = await createTestFile(exifData);
+      assetMock.getByIds.mockResolvedValue([{ id: 'asset-1', originalPath: filePath } as AssetEntity]);
+
+      await sut.handleMetadataExtraction({ id: 'asset-1' });
+
+      expect(assetMock.upsertExif).toHaveBeenCalledWith(
+        expect.objectContaining({
+          dateTimeOriginal: new Date(expected.dateTimeOriginal),
+          timeZone: expected.timeZone,
+        }),
+      );
+
+      expect(assetMock.update).toHaveBeenCalledWith(
+        expect.objectContaining({
+          localDateTime: new Date(expected.localDateTime),
+        }),
+      );
+    });
+  });
+});
diff --git a/server/test/utils.ts b/server/test/utils.ts
index 05257c19ee..3b7e80994d 100644
--- a/server/test/utils.ts
+++ b/server/test/utils.ts
@@ -1,3 +1,5 @@
+import { PNG } from 'pngjs';
+import { IMetadataRepository } from 'src/interfaces/metadata.interface';
 import { BaseService } from 'src/services/base.service';
 import { newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
 import { newActivityRepositoryMock } from 'test/repositories/activity.repository.mock';
@@ -36,13 +38,22 @@ import { newTrashRepositoryMock } from 'test/repositories/trash.repository.mock'
 import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
 import { newVersionHistoryRepositoryMock } from 'test/repositories/version-history.repository.mock';
 import { newViewRepositoryMock } from 'test/repositories/view.repository.mock';
+import { Mocked } from 'vitest';
 
+type RepositoryOverrides = {
+  metadataRepository: IMetadataRepository;
+};
 type BaseServiceArgs = ConstructorParameters<typeof BaseService>;
 type Constructor<Type, Args extends Array<any>> = {
   new (...deps: Args): Type;
 };
 
-export const newTestService = <T extends BaseService>(Service: Constructor<T, BaseServiceArgs>) => {
+export const newTestService = <T extends BaseService>(
+  Service: Constructor<T, BaseServiceArgs>,
+  overrides?: RepositoryOverrides,
+) => {
+  const { metadataRepository } = overrides || {};
+
   const accessMock = newAccessRepositoryMock();
   const loggerMock = newLoggerRepositoryMock();
   const cryptoMock = newCryptoRepositoryMock();
@@ -61,7 +72,7 @@ export const newTestService = <T extends BaseService>(Service: Constructor<T, Ba
   const mapMock = newMapRepositoryMock();
   const mediaMock = newMediaRepositoryMock();
   const memoryMock = newMemoryRepositoryMock();
-  const metadataMock = newMetadataRepositoryMock();
+  const metadataMock = (metadataRepository || newMetadataRepositoryMock()) as Mocked<IMetadataRepository>;
   const metricMock = newMetricRepositoryMock();
   const moveMock = newMoveRepositoryMock();
   const notificationMock = newNotificationRepositoryMock();
@@ -162,3 +173,33 @@ export const newTestService = <T extends BaseService>(Service: Constructor<T, Ba
     viewMock,
   };
 };
+
+const createPNG = (r: number, g: number, b: number) => {
+  const image = new PNG({ width: 1, height: 1 });
+  image.data[0] = r;
+  image.data[1] = g;
+  image.data[2] = b;
+  image.data[3] = 255;
+  return PNG.sync.write(image);
+};
+
+function* newPngFactory() {
+  for (let r = 0; r < 255; r++) {
+    for (let g = 0; g < 255; g++) {
+      for (let b = 0; b < 255; b++) {
+        yield createPNG(r, g, b);
+      }
+    }
+  }
+}
+
+const pngFactory = newPngFactory();
+
+export const newRandomImage = () => {
+  const { value } = pngFactory.next();
+  if (!value) {
+    throw new Error('Ran out of random asset data');
+  }
+
+  return value;
+};
diff --git a/server/vitest.config.medium.mjs b/server/vitest.config.medium.mjs
new file mode 100644
index 0000000000..40dad8d6a5
--- /dev/null
+++ b/server/vitest.config.medium.mjs
@@ -0,0 +1,17 @@
+import swc from 'unplugin-swc';
+import tsconfigPaths from 'vite-tsconfig-paths';
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+  test: {
+    root: './',
+    globals: true,
+    include: ['test/medium/**/*.spec.ts'],
+    server: {
+      deps: {
+        fallbackCJS: true,
+      },
+    },
+  },
+  plugins: [swc.vite(), tsconfigPaths()],
+});
diff --git a/server/vitest.config.mjs b/server/vitest.config.mjs
index ff893736af..df1a9a7654 100644
--- a/server/vitest.config.mjs
+++ b/server/vitest.config.mjs
@@ -6,6 +6,7 @@ export default defineConfig({
   test: {
     root: './',
     globals: true,
+    include: ['src/**/*.spec.ts'],
     coverage: {
       provider: 'v8',
       include: ['src/cores/**', 'src/interfaces/**', 'src/services/**', 'src/utils/**'],