From 0df70365d725e128530fac8a09f092e72b7f54a7 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 4 Nov 2025 16:03:02 -0500 Subject: [PATCH] feat: exif medium tests (#23561) --- .github/workflows/test.yml | 1 + e2e/src/api/specs/asset.e2e-spec.ts | 450 ------------------ e2e/src/generate-date-tag-test-images.ts | 178 ------- e2e/test-assets | 2 +- server/src/services/metadata.service.spec.ts | 16 +- server/src/services/metadata.service.ts | 29 +- server/test/medium.factory.ts | 74 +++ .../medium/specs/exif/exif-date-time.spec.ts | 65 +++ .../test/medium/specs/exif/exif-gps.spec.ts | 31 ++ .../test/medium/specs/exif/exif-tags.spec.ts | 34 ++ .../specs/services/metadata.service.spec.ts | 36 -- 11 files changed, 234 insertions(+), 682 deletions(-) delete mode 100644 e2e/src/generate-date-tag-test-images.ts create mode 100644 server/test/medium/specs/exif/exif-date-time.spec.ts create mode 100644 server/test/medium/specs/exif/exif-gps.spec.ts create mode 100644 server/test/medium/specs/exif/exif-tags.spec.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8c7eae6532..0a63046c0e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -382,6 +382,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: persist-credentials: false + submodules: 'recursive' token: ${{ steps.token.outputs.token }} - name: Setup pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index 90d51e7fef..ab3252c40b 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -15,7 +15,6 @@ import { DateTime } from 'luxon'; import { randomBytes } from 'node:crypto'; import { readFile, writeFile } from 'node:fs/promises'; import { basename, join } from 'node:path'; -import sharp from 'sharp'; import { Socket } from 'socket.io-client'; import { createUserDto, uuidDto } from 'src/fixtures'; import { makeRandomImage } from 'src/generators'; @@ -41,40 +40,6 @@ const today = DateTime.fromObject({ }) as DateTime; const yesterday = today.minus({ days: 1 }); -const createTestImageWithExif = async (filename: string, exifData: Record) => { - // Generate unique color to ensure different checksums for each image - const r = Math.floor(Math.random() * 256); - const g = Math.floor(Math.random() * 256); - const b = Math.floor(Math.random() * 256); - - // Create a 100x100 solid color JPEG using Sharp - const imageBytes = await sharp({ - create: { - width: 100, - height: 100, - channels: 3, - background: { r, g, b }, - }, - }) - .jpeg({ quality: 90 }) - .toBuffer(); - - // Add random suffix to filename to avoid collisions - const uniqueFilename = filename.replace('.jpg', `-${randomBytes(4).toString('hex')}.jpg`); - const filepath = join(tempDir, uniqueFilename); - await writeFile(filepath, imageBytes); - - // Filter out undefined values before writing EXIF - const cleanExifData = Object.fromEntries(Object.entries(exifData).filter(([, value]) => value !== undefined)); - - await exiftool.write(filepath, cleanExifData); - - // Re-read the image bytes after EXIF has been written - const finalImageBytes = await readFile(filepath); - - return { filepath, imageBytes: finalImageBytes, filename: uniqueFilename }; -}; - describe('/asset', () => { let admin: LoginResponseDto; let websocket: Socket; @@ -1140,16 +1105,6 @@ describe('/asset', () => { }, }, }, - { - input: 'metadata/gps-position/empty_gps.jpg', - expected: { - type: AssetTypeEnum.Image, - exifInfo: { - latitude: null, - longitude: null, - }, - }, - }, ]; it.each(tests)(`should upload and generate a thumbnail for different file types`, async ({ input, expected }) => { @@ -1259,411 +1214,6 @@ describe('/asset', () => { }); }); - describe('EXIF metadata extraction', () => { - describe('Additional date tag extraction', () => { - describe('Date-time vs time-only tag handling', () => { - it('should fall back to file timestamps when only time-only tags are available', async () => { - const { imageBytes, filename } = await createTestImageWithExif('time-only-fallback.jpg', { - TimeCreated: '2023:11:15 14:30:00', // Time-only tag, should not be used for dateTimeOriginal - // Exclude all date-time tags to force fallback to file timestamps - SubSecDateTimeOriginal: undefined, - DateTimeOriginal: undefined, - SubSecCreateDate: undefined, - SubSecMediaCreateDate: undefined, - CreateDate: undefined, - MediaCreateDate: undefined, - CreationDate: undefined, - DateTimeCreated: undefined, - GPSDateTime: undefined, - DateTimeUTC: undefined, - SonyDateTime2: undefined, - GPSDateStamp: undefined, - }); - - const oldDate = new Date('2020-01-01T00:00:00.000Z'); - const asset = await utils.createAsset(admin.accessToken, { - assetData: { - filename, - bytes: imageBytes, - }, - fileCreatedAt: oldDate.toISOString(), - fileModifiedAt: oldDate.toISOString(), - }); - - await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id }); - - const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) }); - - expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined(); - // Should fall back to file timestamps, which we set to 2020-01-01 - expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe( - new Date('2020-01-01T00:00:00.000Z').getTime(), - ); - }); - - it('should prefer DateTimeOriginal over time-only tags', async () => { - const { imageBytes, filename } = await createTestImageWithExif('datetime-over-time.jpg', { - DateTimeOriginal: '2023:10:10 10:00:00', // Should be preferred - TimeCreated: '2023:11:15 14:30:00', // Should be ignored (time-only) - }); - - const asset = await utils.createAsset(admin.accessToken, { - assetData: { - filename, - bytes: imageBytes, - }, - }); - - await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id }); - - const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) }); - - expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined(); - // Should use DateTimeOriginal, not TimeCreated - expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe( - new Date('2023-10-10T10:00:00.000Z').getTime(), - ); - }); - }); - - describe('GPSDateTime tag extraction', () => { - it('should extract GPSDateTime with GPS coordinates', async () => { - const { imageBytes, filename } = await createTestImageWithExif('gps-datetime.jpg', { - GPSDateTime: '2023:11:15 12:30:00Z', - GPSLatitude: 37.7749, - GPSLongitude: -122.4194, - // Exclude other date tags - SubSecDateTimeOriginal: undefined, - DateTimeOriginal: undefined, - SubSecCreateDate: undefined, - SubSecMediaCreateDate: undefined, - CreateDate: undefined, - MediaCreateDate: undefined, - CreationDate: undefined, - DateTimeCreated: undefined, - TimeCreated: undefined, - }); - - const asset = await utils.createAsset(admin.accessToken, { - assetData: { - filename, - bytes: imageBytes, - }, - }); - - await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id }); - - const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) }); - - expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined(); - expect(assetInfo.exifInfo?.latitude).toBeCloseTo(37.7749, 4); - expect(assetInfo.exifInfo?.longitude).toBeCloseTo(-122.4194, 4); - expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe( - new Date('2023-11-15T12:30:00.000Z').getTime(), - ); - }); - }); - - describe('CreateDate tag extraction', () => { - it('should extract CreateDate when available', async () => { - const { imageBytes, filename } = await createTestImageWithExif('create-date.jpg', { - CreateDate: '2023:11:15 10:30:00', - // Exclude other higher priority date tags - SubSecDateTimeOriginal: undefined, - DateTimeOriginal: undefined, - SubSecCreateDate: undefined, - SubSecMediaCreateDate: undefined, - MediaCreateDate: undefined, - CreationDate: undefined, - DateTimeCreated: undefined, - TimeCreated: undefined, - GPSDateTime: undefined, - }); - - const asset = await utils.createAsset(admin.accessToken, { - assetData: { - filename, - bytes: imageBytes, - }, - }); - - await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id }); - - const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) }); - - expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined(); - expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe( - new Date('2023-11-15T10:30:00.000Z').getTime(), - ); - }); - }); - - describe('GPSDateStamp tag extraction', () => { - it('should fall back to file timestamps when only date-only tags are available', async () => { - const { imageBytes, filename } = await createTestImageWithExif('gps-datestamp.jpg', { - GPSDateStamp: '2023:11:15', // Date-only tag, should not be used for dateTimeOriginal - // Note: NOT including GPSTimeStamp to avoid automatic GPSDateTime creation - GPSLatitude: 51.5074, - GPSLongitude: -0.1278, - // Explicitly exclude all testable date-time tags to force fallback to file timestamps - DateTimeOriginal: undefined, - CreateDate: undefined, - CreationDate: undefined, - GPSDateTime: undefined, - }); - - const oldDate = new Date('2020-01-01T00:00:00.000Z'); - const asset = await utils.createAsset(admin.accessToken, { - assetData: { - filename, - bytes: imageBytes, - }, - fileCreatedAt: oldDate.toISOString(), - fileModifiedAt: oldDate.toISOString(), - }); - - await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id }); - - const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) }); - - expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined(); - expect(assetInfo.exifInfo?.latitude).toBeCloseTo(51.5074, 4); - expect(assetInfo.exifInfo?.longitude).toBeCloseTo(-0.1278, 4); - // Should fall back to file timestamps, which we set to 2020-01-01 - expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe( - new Date('2020-01-01T00:00:00.000Z').getTime(), - ); - }); - }); - - /* - * NOTE: The following EXIF date tags are NOT effectively usable with JPEG test files: - * - * NOT WRITABLE to JPEG: - * - MediaCreateDate: Can be read from video files but not written to JPEG - * - DateTimeCreated: Read-only tag in JPEG format - * - DateTimeUTC: Cannot be written to JPEG files - * - SonyDateTime2: Proprietary Sony tag, not writable to JPEG - * - SubSecMediaCreateDate: Tag not defined for JPEG format - * - SourceImageCreateTime: Non-standard insta360 tag, not writable to JPEG - * - * WRITABLE but NOT READABLE from JPEG: - * - SubSecDateTimeOriginal: Can be written but not read back from JPEG - * - SubSecCreateDate: Can be written but not read back from JPEG - * - * EFFECTIVELY TESTABLE TAGS (writable and readable): - * - DateTimeOriginal ✓ - * - CreateDate ✓ - * - CreationDate ✓ - * - GPSDateTime ✓ - * - * The metadata service correctly handles non-readable tags and will fall back to - * file timestamps when only non-readable tags are present. - */ - - describe('Date tag priority order', () => { - it('should respect the complete date tag priority order', async () => { - // Test cases using only EFFECTIVELY TESTABLE tags (writable AND readable from JPEG) - const testCases = [ - { - name: 'DateTimeOriginal has highest priority among testable tags', - exifData: { - DateTimeOriginal: '2023:04:04 04:00:00', // TESTABLE - highest priority among readable tags - CreateDate: '2023:05:05 05:00:00', // TESTABLE - CreationDate: '2023:07:07 07:00:00', // TESTABLE - GPSDateTime: '2023:10:10 10:00:00', // TESTABLE - }, - expectedDate: '2023-04-04T04:00:00.000Z', - }, - { - name: 'CreationDate when DateTimeOriginal missing', - exifData: { - CreationDate: '2023:05:05 05:00:00', // TESTABLE - CreateDate: '2023:07:07 07:00:00', // TESTABLE - GPSDateTime: '2023:10:10 10:00:00', // TESTABLE - }, - expectedDate: '2023-05-05T05:00:00.000Z', - }, - { - name: 'CreationDate when standard EXIF tags missing', - exifData: { - CreationDate: '2023:07:07 07:00:00', // TESTABLE - GPSDateTime: '2023:10:10 10:00:00', // TESTABLE - }, - expectedDate: '2023-07-07T07:00:00.000Z', - }, - { - name: 'GPSDateTime when no other testable date tags present', - exifData: { - GPSDateTime: '2023:10:10 10:00:00', // TESTABLE - Make: 'SONY', - }, - expectedDate: '2023-10-10T10:00:00.000Z', - }, - ]; - - for (const testCase of testCases) { - const { imageBytes, filename } = await createTestImageWithExif( - `${testCase.name.replaceAll(/\s+/g, '-').toLowerCase()}.jpg`, - testCase.exifData, - ); - - const asset = await utils.createAsset(admin.accessToken, { - assetData: { - filename, - bytes: imageBytes, - }, - }); - - await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id }); - - const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) }); - - expect(assetInfo.exifInfo?.dateTimeOriginal, `Failed for: ${testCase.name}`).toBeDefined(); - expect( - new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime(), - `Date mismatch for: ${testCase.name}`, - ).toBe(new Date(testCase.expectedDate).getTime()); - } - }); - }); - - describe('Edge cases for date tag handling', () => { - it('should fall back to file timestamps with GPSDateStamp alone', async () => { - const { imageBytes, filename } = await createTestImageWithExif('gps-datestamp-only.jpg', { - GPSDateStamp: '2023:08:08', // Date-only tag, should not be used for dateTimeOriginal - // Intentionally no GPSTimeStamp - // Exclude all other date tags - SubSecDateTimeOriginal: undefined, - DateTimeOriginal: undefined, - SubSecCreateDate: undefined, - SubSecMediaCreateDate: undefined, - CreateDate: undefined, - MediaCreateDate: undefined, - CreationDate: undefined, - DateTimeCreated: undefined, - TimeCreated: undefined, - GPSDateTime: undefined, - DateTimeUTC: undefined, - }); - - const oldDate = new Date('2020-01-01T00:00:00.000Z'); - const asset = await utils.createAsset(admin.accessToken, { - assetData: { - filename, - bytes: imageBytes, - }, - fileCreatedAt: oldDate.toISOString(), - fileModifiedAt: oldDate.toISOString(), - }); - - await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id }); - - const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) }); - - expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined(); - // Should fall back to file timestamps, which we set to 2020-01-01 - expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe( - new Date('2020-01-01T00:00:00.000Z').getTime(), - ); - }); - - it('should handle all testable date tags present to verify complete priority order', async () => { - const { imageBytes, filename } = await createTestImageWithExif('all-testable-date-tags.jpg', { - // All TESTABLE date tags to JPEG format (writable AND readable) - DateTimeOriginal: '2023:04:04 04:00:00', // TESTABLE - highest priority among readable tags - CreateDate: '2023:05:05 05:00:00', // TESTABLE - CreationDate: '2023:07:07 07:00:00', // TESTABLE - GPSDateTime: '2023:10:10 10:00:00', // TESTABLE - // Note: Excluded non-testable tags: - // SubSec tags: writable but not readable from JPEG - // Non-writable tags: MediaCreateDate, DateTimeCreated, DateTimeUTC, SonyDateTime2, etc. - // Time-only/date-only tags: already excluded from EXIF_DATE_TAGS - }); - - const asset = await utils.createAsset(admin.accessToken, { - assetData: { - filename, - bytes: imageBytes, - }, - }); - - await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id }); - - const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) }); - - expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined(); - // Should use DateTimeOriginal as it has the highest priority among testable tags - expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe( - new Date('2023-04-04T04:00:00.000Z').getTime(), - ); - }); - - it('should use CreationDate when SubSec tags are missing', async () => { - const { imageBytes, filename } = await createTestImageWithExif('creation-date-priority.jpg', { - CreationDate: '2023:07:07 07:00:00', // WRITABLE - GPSDateTime: '2023:10:10 10:00:00', // WRITABLE - // Note: DateTimeCreated, DateTimeUTC, SonyDateTime2 are NOT writable to JPEG - // Note: TimeCreated and GPSDateStamp are excluded from EXIF_DATE_TAGS (time-only/date-only) - // Exclude SubSec and standard EXIF tags - SubSecDateTimeOriginal: undefined, - DateTimeOriginal: undefined, - SubSecCreateDate: undefined, - CreateDate: undefined, - }); - - const asset = await utils.createAsset(admin.accessToken, { - assetData: { - filename, - bytes: imageBytes, - }, - }); - - await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id }); - - const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) }); - - expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined(); - // Should use CreationDate when available - expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe( - new Date('2023-07-07T07:00:00.000Z').getTime(), - ); - }); - - it('should skip invalid date formats and use next valid tag', async () => { - const { imageBytes, filename } = await createTestImageWithExif('invalid-date-handling.jpg', { - // Note: Testing invalid date handling with only WRITABLE tags - GPSDateTime: '2023:10:10 10:00:00', // WRITABLE - Valid date - CreationDate: '2023:13:13 13:00:00', // WRITABLE - Valid date - // Note: TimeCreated excluded (time-only), DateTimeCreated not writable to JPEG - // Exclude other date tags - SubSecDateTimeOriginal: undefined, - DateTimeOriginal: undefined, - SubSecCreateDate: undefined, - CreateDate: undefined, - }); - - const asset = await utils.createAsset(admin.accessToken, { - assetData: { - filename, - bytes: imageBytes, - }, - }); - - await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id }); - - const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) }); - - expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined(); - // Should skip invalid dates and use the first valid one (GPSDateTime) - expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe( - new Date('2023-10-10T10:00:00.000Z').getTime(), - ); - }); - }); - }); - }); - describe('POST /assets/exist', () => { it('ignores invalid deviceAssetIds', async () => { const response = await utils.checkExistingAssets(user1.accessToken, { diff --git a/e2e/src/generate-date-tag-test-images.ts b/e2e/src/generate-date-tag-test-images.ts deleted file mode 100644 index 34cc956416..0000000000 --- a/e2e/src/generate-date-tag-test-images.ts +++ /dev/null @@ -1,178 +0,0 @@ -#!/usr/bin/env node - -/** - * Script to generate test images with additional EXIF date tags - * This creates actual JPEG images with embedded metadata for testing - * Images are generated into e2e/test-assets/metadata/dates/ - */ - -import { execSync } from 'node:child_process'; -import { writeFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import sharp from 'sharp'; - -interface TestImage { - filename: string; - description: string; - exifTags: Record; -} - -const testImages: TestImage[] = [ - { - filename: 'time-created.jpg', - description: 'Image with TimeCreated tag', - exifTags: { - TimeCreated: '2023:11:15 14:30:00', - Make: 'Canon', - Model: 'EOS R5', - }, - }, - { - filename: 'gps-datetime.jpg', - description: 'Image with GPSDateTime and coordinates', - exifTags: { - GPSDateTime: '2023:11:15 12:30:00Z', - GPSLatitude: '37.7749', - GPSLongitude: '-122.4194', - GPSLatitudeRef: 'N', - GPSLongitudeRef: 'W', - }, - }, - { - filename: 'datetime-utc.jpg', - description: 'Image with DateTimeUTC tag', - exifTags: { - DateTimeUTC: '2023:11:15 10:30:00', - Make: 'Nikon', - Model: 'D850', - }, - }, - { - filename: 'gps-datestamp.jpg', - description: 'Image with GPSDateStamp and GPSTimeStamp', - exifTags: { - GPSDateStamp: '2023:11:15', - GPSTimeStamp: '08:30:00', - GPSLatitude: '51.5074', - GPSLongitude: '-0.1278', - GPSLatitudeRef: 'N', - GPSLongitudeRef: 'W', - }, - }, - { - filename: 'sony-datetime2.jpg', - description: 'Sony camera image with SonyDateTime2 tag', - exifTags: { - SonyDateTime2: '2023:11:15 06:30:00', - Make: 'SONY', - Model: 'ILCE-7RM5', - }, - }, - { - filename: 'date-priority-test.jpg', - description: 'Image with multiple date tags to test priority', - exifTags: { - SubSecDateTimeOriginal: '2023:01:01 01:00:00', - DateTimeOriginal: '2023:02:02 02:00:00', - SubSecCreateDate: '2023:03:03 03:00:00', - CreateDate: '2023:04:04 04:00:00', - CreationDate: '2023:05:05 05:00:00', - DateTimeCreated: '2023:06:06 06:00:00', - TimeCreated: '2023:07:07 07:00:00', - GPSDateTime: '2023:08:08 08:00:00', - DateTimeUTC: '2023:09:09 09:00:00', - GPSDateStamp: '2023:10:10', - SonyDateTime2: '2023:11:11 11:00:00', - }, - }, - { - filename: 'new-tags-only.jpg', - description: 'Image with only additional date tags (no standard tags)', - exifTags: { - TimeCreated: '2023:12:01 15:45:30', - GPSDateTime: '2023:12:01 13:45:30Z', - DateTimeUTC: '2023:12:01 13:45:30', - GPSDateStamp: '2023:12:01', - SonyDateTime2: '2023:12:01 08:45:30', - GPSLatitude: '40.7128', - GPSLongitude: '-74.0060', - GPSLatitudeRef: 'N', - GPSLongitudeRef: 'W', - }, - }, -]; - -const generateTestImages = async (): Promise => { - // Target directory: e2e/test-assets/metadata/dates/ - // Current file is in: e2e/src/ - const __filename = fileURLToPath(import.meta.url); - const __dirname = dirname(__filename); - const targetDir = join(__dirname, '..', 'test-assets', 'metadata', 'dates'); - - console.log('Generating test images with additional EXIF date tags...'); - console.log(`Target directory: ${targetDir}`); - - for (const image of testImages) { - try { - const imagePath = join(targetDir, image.filename); - - // Create unique JPEG file using Sharp - const r = Math.floor(Math.random() * 256); - const g = Math.floor(Math.random() * 256); - const b = Math.floor(Math.random() * 256); - - const jpegData = await sharp({ - create: { - width: 100, - height: 100, - channels: 3, - background: { r, g, b }, - }, - }) - .jpeg({ quality: 90 }) - .toBuffer(); - - writeFileSync(imagePath, jpegData); - - // Build exiftool command to add EXIF data - const exifArgs = Object.entries(image.exifTags) - .map(([tag, value]) => `-${tag}="${value}"`) - .join(' '); - - const command = `exiftool ${exifArgs} -overwrite_original "${imagePath}"`; - - console.log(`Creating ${image.filename}: ${image.description}`); - execSync(command, { stdio: 'pipe' }); - - // Verify the tags were written - const verifyCommand = `exiftool -json "${imagePath}"`; - const result = execSync(verifyCommand, { encoding: 'utf8' }); - const metadata = JSON.parse(result)[0]; - - console.log(` ✓ Created with ${Object.keys(image.exifTags).length} EXIF tags`); - - // Log first date tag found for verification - const firstDateTag = Object.keys(image.exifTags).find( - (tag) => tag.includes('Date') || tag.includes('Time') || tag.includes('Created'), - ); - if (firstDateTag && metadata[firstDateTag]) { - console.log(` ✓ Verified ${firstDateTag}: ${metadata[firstDateTag]}`); - } - } catch (error) { - console.error(`Failed to create ${image.filename}:`, (error as Error).message); - } - } - - console.log('\nTest image generation complete!'); - console.log('Files created in:', targetDir); - console.log('\nTo test these images:'); - console.log(`cd ${targetDir} && exiftool -time:all -gps:all *.jpg`); -}; - -export { generateTestImages }; - -// Run the generator if this file is executed directly -if (import.meta.url === `file://${process.argv[1]}`) { - generateTestImages().catch(console.error); -} diff --git a/e2e/test-assets b/e2e/test-assets index 68e8b5853c..163c251744 160000 --- a/e2e/test-assets +++ b/e2e/test-assets @@ -1 +1 @@ -Subproject commit 68e8b5853cdc2d76c5e6f18a6d1773793728c491 +Subproject commit 163c251744e0a35d7ecfd02682452043f149fc2b diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 0adb390f6a..220216a2c8 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -1,4 +1,5 @@ import { BinaryField, ExifDateTime } from 'exiftool-vendored'; +import { DateTime } from 'luxon'; import { randomBytes } from 'node:crypto'; import { Stats } from 'node:fs'; import { defaults } from 'src/config'; @@ -231,7 +232,7 @@ describe(MetadataService.name, () => { }); }); - it('should account for the server being in a non-UTC timezone', async () => { + it('should determine dateTimeOriginal regardless of the server time zone', async () => { process.env.TZ = 'America/Los_Angeles'; mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.sidecar); mockReadTags({ DateTimeOriginal: '2022:01:01 00:00:00' }); @@ -239,7 +240,7 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: assetStub.image.id }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ - dateTimeOriginal: new Date('2022-01-01T08:00:00.000Z'), + dateTimeOriginal: new Date('2022-01-01T00:00:00.000Z'), }), ); @@ -856,6 +857,7 @@ describe(MetadataService.name, () => { tz: 'UTC-11:30', Rating: 3, }; + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); mockReadTags(tags); @@ -897,7 +899,7 @@ describe(MetadataService.name, () => { id: assetStub.image.id, duration: null, fileCreatedAt: dateForTest, - localDateTime: dateForTest, + localDateTime: DateTime.fromISO('1970-01-01T00:00:00.000Z').toJSDate(), }), ); }); @@ -1595,7 +1597,7 @@ describe(MetadataService.name, () => { const result = firstDateTime(tags); expect(result?.tag).toBe('SonyDateTime2'); - expect(result?.dateTime?.toDate()?.toISOString()).toBe('2023-07-07T07:00:00.000Z'); + expect(result?.dateTime?.toISOString()).toBe('2023-07-07T07:00:00'); }); it('should respect full priority order with all date tags present', () => { @@ -1624,7 +1626,7 @@ describe(MetadataService.name, () => { const result = firstDateTime(tags); // Should use SubSecDateTimeOriginal as it has highest priority expect(result?.tag).toBe('SubSecDateTimeOriginal'); - expect(result?.dateTime?.toDate()?.toISOString()).toBe('2023-01-01T01:00:00.000Z'); + expect(result?.dateTime?.toISOString()).toBe('2023-01-01T01:00:00'); }); it('should handle missing SubSec tags and use available date tags', () => { @@ -1644,7 +1646,7 @@ describe(MetadataService.name, () => { const result = firstDateTime(tags); // Should use CreationDate when available expect(result?.tag).toBe('CreationDate'); - expect(result?.dateTime?.toDate()?.toISOString()).toBe('2023-07-07T07:00:00.000Z'); + expect(result?.dateTime?.toISOString()).toBe('2023-07-07T07:00:00'); }); it('should handle invalid date formats gracefully', () => { @@ -1658,7 +1660,7 @@ describe(MetadataService.name, () => { const result = firstDateTime(tags); // Should skip invalid dates and use the first valid one expect(result?.tag).toBe('GPSDateTime'); - expect(result?.dateTime?.toDate()?.toISOString()).toBe('2023-10-10T10:00:00.000Z'); + expect(result?.dateTime?.toISOString()).toBe('2023-10-10T10:00:00'); }); it('should prefer CreationDate over CreateDate', () => { diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index e76b335c90..746f62a944 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { ContainerDirectoryItem, ExifDateTime, Tags } from 'exiftool-vendored'; import { Insertable } from 'kysely'; import _ from 'lodash'; -import { Duration } from 'luxon'; +import { DateTime, Duration } from 'luxon'; import { Stats } from 'node:fs'; import { constants } from 'node:fs/promises'; import { join, parse } from 'node:path'; @@ -866,31 +866,40 @@ export class MetadataService extends BaseService { this.logger.debug(`No timezone information found for asset ${asset.id}: ${asset.originalPath}`); } - let dateTimeOriginal = dateTime?.toDate(); - let localDateTime = dateTime?.toDateTime().setZone('UTC', { keepLocalTime: true }).toJSDate(); + let dateTimeOriginal = dateTime?.toDateTime(); + + // do not let JavaScript use local timezone + if (dateTimeOriginal && !dateTime?.hasZone) { + dateTimeOriginal = dateTimeOriginal.setZone('UTC', { keepLocalTime: true }); + } + + // align with whatever timeZone we chose + dateTimeOriginal = dateTimeOriginal?.setZone(timeZone ?? 'UTC'); + + // store as "local time" + let localDateTime = dateTimeOriginal?.setZone('UTC', { keepLocalTime: true }); + if (!localDateTime || !dateTimeOriginal) { // FileCreateDate is not available on linux, likely because exiftool hasn't integrated the statx syscall yet // birthtime is not available in Docker on macOS, so it appears as 0 - const earliestDate = new Date( + const earliestDate = DateTime.fromMillis( Math.min( asset.fileCreatedAt.getTime(), stats.birthtimeMs ? Math.min(stats.mtimeMs, stats.birthtimeMs) : stats.mtime.getTime(), ), ); this.logger.debug( - `No exif date time found, falling back on ${earliestDate.toISOString()}, earliest of file creation and modification for asset ${asset.id}: ${asset.originalPath}`, + `No exif date time found, falling back on ${earliestDate.toISO()}, earliest of file creation and modification for asset ${asset.id}: ${asset.originalPath}`, ); dateTimeOriginal = localDateTime = earliestDate; } - this.logger.verbose( - `Found local date time ${localDateTime.toISOString()} for asset ${asset.id}: ${asset.originalPath}`, - ); + this.logger.verbose(`Found local date time ${localDateTime.toISO()} for asset ${asset.id}: ${asset.originalPath}`); return { - dateTimeOriginal, timeZone, - localDateTime, + localDateTime: localDateTime.toJSDate(), + dateTimeOriginal: dateTimeOriginal.toJSDate(), }; } diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index f332cc02e6..35bcb45413 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -2,6 +2,7 @@ import { Insertable, Kysely } from 'kysely'; import { DateTime } from 'luxon'; import { createHash, randomBytes } from 'node:crypto'; +import { Stats } from 'node:fs'; import { Writable } from 'node:stream'; import { AssetFace } from 'src/database'; import { AuthDto, LoginResponseDto } from 'src/dtos/auth.dto'; @@ -28,7 +29,9 @@ import { EventRepository } from 'src/repositories/event.repository'; import { JobRepository } from 'src/repositories/job.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { MachineLearningRepository } from 'src/repositories/machine-learning.repository'; +import { MapRepository } from 'src/repositories/map.repository'; import { MemoryRepository } from 'src/repositories/memory.repository'; +import { MetadataRepository } from 'src/repositories/metadata.repository'; import { NotificationRepository } from 'src/repositories/notification.repository'; import { OcrRepository } from 'src/repositories/ocr.repository'; import { PartnerRepository } from 'src/repositories/partner.repository'; @@ -61,7 +64,9 @@ import { TagAssetTable } from 'src/schema/tables/tag-asset.table'; import { TagTable } from 'src/schema/tables/tag.table'; import { UserTable } from 'src/schema/tables/user.table'; import { BASE_SERVICE_DEPENDENCIES, BaseService } from 'src/services/base.service'; +import { MetadataService } from 'src/services/metadata.service'; import { SyncService } from 'src/services/sync.service'; +import { mockEnvData } from 'test/repositories/config.repository.mock'; import { newTelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock'; import { factory, newDate, newEmbedding, newUuid } from 'test/small.factory'; import { automock, wait } from 'test/utils'; @@ -305,6 +310,63 @@ export class SyncTestContext extends MediumTestContext { } } +const mockDate = new Date('2024-06-01T12:00:00.000Z'); +const mockStats = { + mtime: mockDate, + atime: mockDate, + ctime: mockDate, + birthtime: mockDate, + atimeMs: 0, + mtimeMs: 0, + ctimeMs: 0, + birthtimeMs: 0, +}; + +export class ExifTestContext extends MediumTestContext { + constructor(database: Kysely) { + super(MetadataService, { + database, + real: [AssetRepository, AssetJobRepository, MetadataRepository, SystemMetadataRepository, TagRepository], + mock: [ConfigRepository, EventRepository, LoggingRepository, MapRepository, StorageRepository], + }); + + this.getMock(ConfigRepository).getEnv.mockReturnValue(mockEnvData({})); + this.getMock(EventRepository).emit.mockResolvedValue(); + this.getMock(MapRepository).reverseGeocode.mockResolvedValue({ country: null, state: null, city: null }); + this.getMock(StorageRepository).stat.mockResolvedValue(mockStats as Stats); + } + + getMockStats() { + return mockStats; + } + + getGps(assetId: string) { + return this.database + .selectFrom('asset_exif') + .select(['latitude', 'longitude']) + .where('assetId', '=', assetId) + .executeTakeFirstOrThrow(); + } + + getTags(assetId: string) { + return this.database + .selectFrom('tag') + .innerJoin('tag_asset', 'tag.id', 'tag_asset.tagsId') + .where('tag_asset.assetsId', '=', assetId) + .selectAll() + .execute(); + } + + getDates(assetId: string) { + return this.database + .selectFrom('asset') + .innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId') + .where('id', '=', assetId) + .select(['asset.fileCreatedAt', 'asset.localDateTime', 'asset_exif.dateTimeOriginal', 'asset_exif.timeZone']) + .executeTakeFirstOrThrow(); + } +} + const newRealRepository = (key: ClassConstructor, db: Kysely): T => { switch (key) { case AccessRepository: @@ -344,6 +406,14 @@ const newRealRepository = (key: ClassConstructor, db: Kysely): T => { return new key(LoggingRepository.create()); } + case MetadataRepository: { + return new key(LoggingRepository.create()); + } + + case StorageRepository: { + return new key(LoggingRepository.create()); + } + case TagRepository: { return new key(db, LoggingRepository.create()); } @@ -381,6 +451,10 @@ const newMockRepository = (key: ClassConstructor) => { return automock(key); } + case MapRepository: { + return automock(MapRepository, { args: [undefined, undefined, { setContext: () => {} }] }); + } + case TelemetryRepository: { return newTelemetryRepositoryMock(); } diff --git a/server/test/medium/specs/exif/exif-date-time.spec.ts b/server/test/medium/specs/exif/exif-date-time.spec.ts new file mode 100644 index 0000000000..e46f17855e --- /dev/null +++ b/server/test/medium/specs/exif/exif-date-time.spec.ts @@ -0,0 +1,65 @@ +import { Kysely } from 'kysely'; +import { DateTime } from 'luxon'; +import { resolve } from 'node:path'; +import { DB } from 'src/schema'; +import { ExifTestContext } from 'test/medium.factory'; +import { getKyselyDB } from 'test/utils'; + +let database: Kysely; + +const setup = async (testAssetPath: string) => { + const ctx = new ExifTestContext(database); + + const { user } = await ctx.newUser(); + const originalPath = resolve(`../e2e/test-assets/${testAssetPath}`); + const { asset } = await ctx.newAsset({ ownerId: user.id, originalPath }); + + return { ctx, sut: ctx.sut, asset }; +}; + +beforeAll(async () => { + database = await getKyselyDB(); +}); + +describe('exif date time', () => { + it('should prioritize DateTimeOriginal', async () => { + const { ctx, sut, asset } = await setup('metadata/dates/date-priority-test.jpg'); + + await sut.handleMetadataExtraction({ id: asset.id }); + + await expect(ctx.getDates(asset.id)).resolves.toEqual({ + timeZone: null, + dateTimeOriginal: DateTime.fromISO('2023-02-02T02:00:00.000Z').toJSDate(), + localDateTime: DateTime.fromISO('2023-02-02T02:00:00.000Z').toJSDate(), + fileCreatedAt: DateTime.fromISO('2023-02-02T02:00:00.000Z').toJSDate(), + }); + }); + + it('should extract GPSDateTime with GPS coordinates ', async () => { + const { ctx, sut, asset } = await setup('metadata/dates/gps-datetime.jpg'); + + await sut.handleMetadataExtraction({ id: asset.id }); + + await expect(ctx.getDates(asset.id)).resolves.toEqual({ + timeZone: 'America/Los_Angeles', + dateTimeOriginal: DateTime.fromISO('2023-11-15T12:30:00.000Z').toJSDate(), + localDateTime: DateTime.fromISO('2023-11-15T04:30:00.000Z').toJSDate(), + fileCreatedAt: DateTime.fromISO('2023-11-15T12:30:00.000Z').toJSDate(), + }); + }); + + it('should ignore the TimeCreated tag', async () => { + const { ctx, sut, asset } = await setup('metadata/dates/time-created.jpg'); + + await sut.handleMetadataExtraction({ id: asset.id }); + + const stats = ctx.getMockStats(); + + await expect(ctx.getDates(asset.id)).resolves.toEqual({ + timeZone: null, + dateTimeOriginal: stats.mtime, + localDateTime: stats.mtime, + fileCreatedAt: stats.mtime, + }); + }); +}); diff --git a/server/test/medium/specs/exif/exif-gps.spec.ts b/server/test/medium/specs/exif/exif-gps.spec.ts new file mode 100644 index 0000000000..651321b599 --- /dev/null +++ b/server/test/medium/specs/exif/exif-gps.spec.ts @@ -0,0 +1,31 @@ +import { Kysely } from 'kysely'; +import { resolve } from 'node:path'; +import { DB } from 'src/schema'; +import { ExifTestContext } from 'test/medium.factory'; +import { getKyselyDB } from 'test/utils'; + +let database: Kysely; + +const setup = async (testAssetPath: string) => { + const ctx = new ExifTestContext(database); + + const { user } = await ctx.newUser(); + const originalPath = resolve(`../e2e/test-assets/${testAssetPath}`); + const { asset } = await ctx.newAsset({ ownerId: user.id, originalPath }); + + return { ctx, sut: ctx.sut, asset }; +}; + +beforeAll(async () => { + database = await getKyselyDB(); +}); + +describe('exif gps', () => { + it('should handle empty strings', async () => { + const { ctx, sut, asset } = await setup('metadata/gps-position/empty_gps.jpg'); + + await sut.handleMetadataExtraction({ id: asset.id }); + + await expect(ctx.getGps(asset.id)).resolves.toEqual({ latitude: null, longitude: null }); + }); +}); diff --git a/server/test/medium/specs/exif/exif-tags.spec.ts b/server/test/medium/specs/exif/exif-tags.spec.ts new file mode 100644 index 0000000000..aee79f4ec1 --- /dev/null +++ b/server/test/medium/specs/exif/exif-tags.spec.ts @@ -0,0 +1,34 @@ +import { Kysely } from 'kysely'; +import { resolve } from 'node:path'; +import { DB } from 'src/schema'; +import { ExifTestContext } from 'test/medium.factory'; +import { getKyselyDB } from 'test/utils'; + +let database: Kysely; + +const setup = async (testAssetPath: string) => { + const ctx = new ExifTestContext(database); + + const { user } = await ctx.newUser(); + const originalPath = resolve(`../e2e/test-assets/${testAssetPath}`); + const { asset } = await ctx.newAsset({ ownerId: user.id, originalPath }); + + return { ctx, sut: ctx.sut, asset }; +}; + +beforeAll(async () => { + database = await getKyselyDB(); +}); + +describe('exif tags', () => { + it('should detect and regular tags', async () => { + const { ctx, sut, asset } = await setup('metadata/tags/picasa.jpg'); + + await sut.handleMetadataExtraction({ id: asset.id }); + + await expect(ctx.getTags(asset.id)).resolves.toEqual([ + expect.objectContaining({ assetsId: asset.id, value: 'Frost', parentId: null }), + expect.objectContaining({ assetsId: asset.id, value: 'Yard', parentId: null }), + ]); + }); +}); diff --git a/server/test/medium/specs/services/metadata.service.spec.ts b/server/test/medium/specs/services/metadata.service.spec.ts index 13b9867373..5d44079be5 100644 --- a/server/test/medium/specs/services/metadata.service.spec.ts +++ b/server/test/medium/specs/services/metadata.service.spec.ts @@ -65,42 +65,6 @@ describe(MetadataService.name, () => { 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: {