diff --git a/e2e/package-lock.json b/e2e/package-lock.json index acc20b97f8..ecf78cf9a9 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -34,6 +34,7 @@ "pngjs": "^7.0.0", "prettier": "^3.2.5", "prettier-plugin-organize-imports": "^4.0.0", + "sharp": "^0.33.5", "socket.io-client": "^4.7.4", "supertest": "^7.0.0", "typescript": "^5.3.3", @@ -178,6 +179,17 @@ "node": ">=18" } }, + "node_modules/@emnapi/runtime": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz", + "integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", @@ -823,6 +835,386 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@immich/cli": { "resolved": "../cli", "link": true @@ -2447,6 +2839,20 @@ "node": ">=0.8.0" } }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2467,6 +2873,17 @@ "dev": true, "license": "MIT" }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, "node_modules/color-support": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", @@ -3964,6 +4381,13 @@ "dev": true, "license": "ISC" }, + "node_modules/is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-builtin-module": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-5.0.0.tgz", @@ -5536,6 +5960,46 @@ "dev": true, "license": "ISC" }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -5655,6 +6119,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, "node_modules/socket.io-client": { "version": "4.8.1", "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz", diff --git a/e2e/package.json b/e2e/package.json index 5c31d23ed1..d2d5857ed5 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -44,6 +44,7 @@ "pngjs": "^7.0.0", "prettier": "^3.2.5", "prettier-plugin-organize-imports": "^4.0.0", + "sharp": "^0.33.5", "socket.io-client": "^4.7.4", "supertest": "^7.0.0", "typescript": "^5.3.3", diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index 4673db5426..c1e9f9dfb8 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -15,6 +15,7 @@ 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'; @@ -40,6 +41,40 @@ 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; @@ -1190,6 +1225,411 @@ 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: 'CreateDate when DateTimeOriginal missing', + exifData: { + 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-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 new file mode 100644 index 0000000000..34cc956416 --- /dev/null +++ b/e2e/src/generate-date-tag-test-images.ts @@ -0,0 +1,178 @@ +#!/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 8885d6d01c..18736fc27a 160000 --- a/e2e/test-assets +++ b/e2e/test-assets @@ -1 +1 @@ -Subproject commit 8885d6d01c12242785b6ea68f4a277334f60bc90 +Subproject commit 18736fc27a80c99c68e856cdb4f842bc81ed3445 diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 7b2cba1250..5386b5fb12 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -6,7 +6,7 @@ import { defaults } from 'src/config'; import { MapAsset } from 'src/dtos/asset-response.dto'; import { AssetType, AssetVisibility, ExifOrientation, ImmichWorker, JobName, JobStatus, SourceType } from 'src/enum'; import { ImmichTags } from 'src/repositories/metadata.repository'; -import { MetadataService } from 'src/services/metadata.service'; +import { firstDateTime, MetadataService } from 'src/services/metadata.service'; import { assetStub } from 'test/fixtures/asset.stub'; import { fileStub } from 'test/fixtures/file.stub'; import { probeStub } from 'test/fixtures/media.stub'; @@ -1639,4 +1639,80 @@ describe(MetadataService.name, () => { }); }); }); + + describe('firstDateTime', () => { + it('should ignore date-only tags like GPSDateStamp', () => { + const tags = { + GPSDateStamp: '2023:08:08', // Date-only tag, should be ignored + SonyDateTime2: '2023:07:07 07:00:00', + }; + + const result = firstDateTime(tags); + expect(result?.tag).toBe('SonyDateTime2'); + expect(result?.dateTime?.toDate()?.toISOString()).toBe('2023-07-07T07:00:00.000Z'); + }); + + it('should respect full priority order with all date tags present', () => { + const tags = { + // SubSec and standard EXIF date tags + SubSecDateTimeOriginal: '2023:01:01 01:00:00', + SubSecCreateDate: '2023:02:02 02:00:00', + SubSecMediaCreateDate: '2023:03:03 03:00:00', + DateTimeOriginal: '2023:04:04 04:00:00', + CreateDate: '2023:05:05 05:00:00', + MediaCreateDate: '2023:06:06 06:00:00', + CreationDate: '2023:07:07 07:00:00', + DateTimeCreated: '2023:08:08 08:00:00', + + // Additional date tags + TimeCreated: '2023:09:09 09:00:00', + GPSDateTime: '2023:10:10 10:00:00', + DateTimeUTC: '2023:11:11 11:00:00', + GPSDateStamp: '2023:12:12', // Date-only tag, should be ignored + SonyDateTime2: '2023:13:13 13:00:00', + + // Non-standard tag + SourceImageCreateTime: '2023:14:14 14:00:00', + }; + + 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'); + }); + + it('should handle missing SubSec tags and use available date tags', () => { + const tags = { + // Standard date tags + CreationDate: '2023:07:07 07:00:00', + DateTimeCreated: '2023:08:08 08:00:00', + + // Additional date tags + TimeCreated: '2023:09:09 09:00:00', + GPSDateTime: '2023:10:10 10:00:00', + DateTimeUTC: '2023:11:11 11:00:00', + GPSDateStamp: '2023:12:12', // Date-only tag, should be ignored + SonyDateTime2: '2023:13:13 13:00:00', + }; + + 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'); + }); + + it('should handle invalid date formats gracefully', () => { + const tags = { + TimeCreated: 'invalid-date', + GPSDateTime: '2023:10:10 10:00:00', + DateTimeUTC: 'also-invalid', + SonyDateTime2: '2023:13:13 13:00:00', + }; + + 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'); + }); + }); }); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 109f5f6936..7c6d659124 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -1,6 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { ContainerDirectoryItem, Maybe, Tags } from 'exiftool-vendored'; -import { firstDateTime } from 'exiftool-vendored/dist/FirstDateTime'; +import { ContainerDirectoryItem, ExifDateTime, Tags } from 'exiftool-vendored'; import { Insertable } from 'kysely'; import _ from 'lodash'; import { Duration } from 'luxon'; @@ -32,19 +31,47 @@ import { isFaceImportEnabled } from 'src/utils/misc'; import { upsertTags } from 'src/utils/tag'; /** look for a date from these tags (in order) */ -const EXIF_DATE_TAGS: Array = [ +const EXIF_DATE_TAGS: Array = [ 'SubSecDateTimeOriginal', - 'DateTimeOriginal', 'SubSecCreateDate', - 'CreationDate', - 'CreateDate', 'SubSecMediaCreateDate', + 'DateTimeOriginal', + 'CreateDate', 'MediaCreateDate', + 'CreationDate', 'DateTimeCreated', + 'GPSDateTime', + 'DateTimeUTC', + 'SonyDateTime2', // Undocumented, non-standard tag from insta360 in xmp.GPano namespace - 'SourceImageCreateTime' as keyof Tags, + 'SourceImageCreateTime' as keyof ImmichTags, ]; +export function firstDateTime(tags: ImmichTags) { + for (const tag of EXIF_DATE_TAGS) { + const tagValue = tags?.[tag]; + + if (tagValue instanceof ExifDateTime) { + return { + tag, + dateTime: tagValue, + }; + } + + if (typeof tagValue !== 'string') { + continue; + } + + const exifDateTime = ExifDateTime.fromEXIF(tagValue); + if (exifDateTime) { + return { + tag, + dateTime: exifDateTime, + }; + } + } +} + const validate = (value: T): NonNullable | null => { // handle lists of numbers if (Array.isArray(value)) { @@ -407,7 +434,8 @@ export class MetadataService extends BaseService { // prefer dates from sidecar tags if (sidecarTags) { - const sidecarDate = firstDateTime(sidecarTags as Tags, EXIF_DATE_TAGS); + const result = firstDateTime(sidecarTags); + const sidecarDate = result?.dateTime; if (sidecarDate) { for (const tag of EXIF_DATE_TAGS) { delete mediaTags[tag]; @@ -748,8 +776,12 @@ export class MetadataService extends BaseService { } private getDates(asset: { id: string; originalPath: string }, exifTags: ImmichTags, stats: Stats) { - const dateTime = firstDateTime(exifTags as Maybe, EXIF_DATE_TAGS); - this.logger.verbose(`Date and time is ${dateTime} for asset ${asset.id}: ${asset.originalPath}`); + const result = firstDateTime(exifTags); + const tag = result?.tag; + const dateTime = result?.dateTime; + this.logger.verbose( + `Date and time is ${dateTime} using exifTag ${tag} for asset ${asset.id}: ${asset.originalPath}`, + ); // timezone let timeZone = exifTags.tz ?? null;