From 87a7825cbc2ddfbcf8379414794d2b5f3919b1d0 Mon Sep 17 00:00:00 2001 From: Fynn Petersen-Frey <10599762+fyfrey@users.noreply.github.com> Date: Wed, 28 Feb 2024 02:32:07 +0100 Subject: [PATCH 01/13] feat(server): rkmpp hardware decoding scaling (#7472) * feat(server): RKMPP hardware decode & scaling * disable hardware decoding for HDR --- server/src/domain/media/media.service.spec.ts | 12 ++++----- server/src/domain/media/media.util.ts | 26 +++++++++++++++---- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/server/src/domain/media/media.service.spec.ts b/server/src/domain/media/media.service.spec.ts index dc5934e7b1..0944016379 100644 --- a/server/src/domain/media/media.service.spec.ts +++ b/server/src/domain/media/media.service.spec.ts @@ -1799,7 +1799,7 @@ describe(MediaService.name, () => { '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', { - inputOptions: [], + inputOptions: ['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga'], outputOptions: [ `-c:v hevc_rkmpp`, '-c:a copy', @@ -1810,9 +1810,9 @@ describe(MediaService.name, () => { '-g 256', '-tag:v hvc1', '-v verbose', - '-vf scale=-2:720,format=yuv420p', + '-vf scale_rkrga=-2:720:format=nv12:afbc=1', '-level 153', - '-rc_mode 3', + '-rc_mode AVBR', '-b:v 10000k', ], twoPass: false, @@ -1834,7 +1834,7 @@ describe(MediaService.name, () => { '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', { - inputOptions: [], + inputOptions: ['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga'], outputOptions: [ `-c:v h264_rkmpp`, '-c:a copy', @@ -1844,9 +1844,9 @@ describe(MediaService.name, () => { '-map 0:1', '-g 256', '-v verbose', - '-vf scale=-2:720,format=yuv420p', + '-vf scale_rkrga=-2:720:format=nv12:afbc=1', '-level 51', - '-rc_mode 2', + '-rc_mode CQP', '-qp_init 30', ], twoPass: false, diff --git a/server/src/domain/media/media.util.ts b/server/src/domain/media/media.util.ts index e5890bdd03..d5f08ab0de 100644 --- a/server/src/domain/media/media.util.ts +++ b/server/src/domain/media/media.util.ts @@ -14,7 +14,7 @@ class BaseConfig implements VideoCodecSWConfig { getOptions(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) { const options = { - inputOptions: this.getBaseInputOptions(), + inputOptions: this.getBaseInputOptions(videoStream), outputOptions: [...this.getBaseOutputOptions(target, videoStream, audioStream), '-v verbose'], twoPass: this.eligibleForTwoPass(), } as TranscodeOptions; @@ -30,7 +30,8 @@ class BaseConfig implements VideoCodecSWConfig { return options; } - getBaseInputOptions(): string[] { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getBaseInputOptions(videoStream: VideoStreamInfo): string[] { return []; } @@ -611,10 +612,25 @@ export class RKMPPConfig extends BaseHWConfig { return false; } - getBaseInputOptions() { + getBaseInputOptions(videoStream: VideoStreamInfo) { if (this.devices.length === 0) { throw new Error('No RKMPP device found'); } + if (this.shouldToneMap(videoStream)) { + // disable hardware decoding + return []; + } + return ['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga']; + } + + getFilterOptions(videoStream: VideoStreamInfo) { + if (this.shouldToneMap(videoStream)) { + // use software filter options + return super.getFilterOptions(videoStream); + } + if (this.shouldScale(videoStream)) { + return [`scale_rkrga=${this.getScaling(videoStream)}:format=nv12:afbc=1`]; + } return []; } @@ -638,10 +654,10 @@ export class RKMPPConfig extends BaseHWConfig { const bitrate = this.getMaxBitrateValue(); if (bitrate > 0) { // -b:v specifies max bitrate, average bitrate is derived automatically... - return ['-rc_mode 3', `-b:v ${bitrate}${this.getBitrateUnit()}`]; + return ['-rc_mode AVBR', `-b:v ${bitrate}${this.getBitrateUnit()}`]; } // use CRF value as QP value - return ['-rc_mode 2', `-qp_init ${this.config.crf}`]; + return ['-rc_mode CQP', `-qp_init ${this.config.crf}`]; } getSupportedCodecs() { From a02a24f349fbc8778dc8d9c57c535cbbbdc47577 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 27 Feb 2024 23:09:40 -0600 Subject: [PATCH 02/13] chore: post release tasks --- mobile/ios/Runner.xcodeproj/project.pbxproj | 6 +++--- mobile/ios/Runner/Info.plist | 4 ++-- mobile/ios/fastlane/report.xml | 12 ++++++------ 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index e9c5dda27e..daa6bff38f 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -379,7 +379,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 139; + CURRENT_PROJECT_VERSION = 140; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -515,7 +515,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 139; + CURRENT_PROJECT_VERSION = 140; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -543,7 +543,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 139; + CURRENT_PROJECT_VERSION = 140; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 30ec0055c2..e4356050c5 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -55,11 +55,11 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.95.0 + 1.96.0 CFBundleSignature ???? CFBundleVersion - 139 + 140 FLTEnableImpeller ITSAppUsesNonExemptEncryption diff --git a/mobile/ios/fastlane/report.xml b/mobile/ios/fastlane/report.xml index 7e27b4085d..dc478d8ee3 100644 --- a/mobile/ios/fastlane/report.xml +++ b/mobile/ios/fastlane/report.xml @@ -5,32 +5,32 @@ - + - + - + - + - + - + From e2c0945bc12398394503161c36307a89d8ca752e Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 27 Feb 2024 23:09:48 -0600 Subject: [PATCH 03/13] chore: post release tasks --- mobile/android/fastlane/report.xml | 6 ++-- mobile/pubspec.lock | 56 +++++++++--------------------- 2 files changed, 19 insertions(+), 43 deletions(-) diff --git a/mobile/android/fastlane/report.xml b/mobile/android/fastlane/report.xml index 585549fdb6..c5442f6cdc 100644 --- a/mobile/android/fastlane/report.xml +++ b/mobile/android/fastlane/report.xml @@ -5,17 +5,17 @@ - + - + - + diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 9e379d4653..f27351898d 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -413,10 +413,10 @@ packages: dependency: transitive description: name: file - sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" + sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d" url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "6.1.4" file_selector_linux: dependency: transitive description: @@ -860,30 +860,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.8.1" - leak_tracker: - dependency: transitive - description: - name: leak_tracker - sha256: "78eb209deea09858f5269f5a5b02be4049535f568c07b275096836f01ea323fa" - url: "https://pub.dev" - source: hosted - version: "10.0.0" - leak_tracker_flutter_testing: - dependency: transitive - description: - name: leak_tracker_flutter_testing - sha256: b46c5e37c19120a8a01918cfaf293547f47269f7cb4b0058f21531c2465d6ef0 - url: "https://pub.dev" - source: hosted - version: "2.0.1" - leak_tracker_testing: - dependency: transitive - description: - name: leak_tracker_testing - sha256: a597f72a664dbd293f3bfc51f9ba69816f84dcd403cdac7066cb3f6003f3ab47 - url: "https://pub.dev" - source: hosted - version: "2.0.1" lints: dependency: transitive description: @@ -931,18 +907,18 @@ packages: dependency: transitive description: name: matcher - sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" url: "https://pub.dev" source: hosted - version: "0.12.16+1" + version: "0.12.16" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41" url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "0.5.0" meta: dependency: "direct overridden" description: @@ -1026,10 +1002,10 @@ packages: dependency: "direct main" description: name: path - sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917" url: "https://pub.dev" source: hosted - version: "1.9.0" + version: "1.8.3" path_provider: dependency: "direct main" description: @@ -1162,10 +1138,10 @@ packages: dependency: transitive description: name: platform - sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" + sha256: ae68c7bfcd7383af3629daafb32fb4e8681c7154428da4febcff06200585f102 url: "https://pub.dev" source: hosted - version: "3.1.4" + version: "3.1.2" plugin_platform_interface: dependency: transitive description: @@ -1194,10 +1170,10 @@ packages: dependency: transitive description: name: process - sha256: "21e54fd2faf1b5bdd5102afd25012184a6793927648ea81eea80552ac9405b32" + sha256: "53fd8db9cec1d37b0574e12f07520d582019cb6c44abf5479a01505099a34a09" url: "https://pub.dev" source: hosted - version: "5.0.2" + version: "4.2.4" provider: dependency: transitive description: @@ -1663,10 +1639,10 @@ packages: dependency: transitive description: name: vm_service - sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957 + sha256: c538be99af830f478718b51630ec1b6bee5e74e52c8a802d328d9e71d35d2583 url: "https://pub.dev" source: hosted - version: "13.0.0" + version: "11.10.0" wakelock_plus: dependency: "direct main" description: @@ -1711,10 +1687,10 @@ packages: dependency: transitive description: name: webdriver - sha256: "003d7da9519e1e5f329422b36c4dcdf18d7d2978d1ba099ea4e45ba490ed845e" + sha256: "3c923e918918feeb90c4c9fdf1fe39220fa4c0e8e2c0fffaded174498ef86c49" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.2" win32: dependency: transitive description: From 74d431f88198fe8c07276c2ac3b8890bcfa36ce7 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 28 Feb 2024 04:21:31 -0500 Subject: [PATCH 04/13] refactor(server): format and metadata e2e (#7477) * refactor(server): format and metadata e2e * refactor: on upload success waiting --- e2e/package-lock.json | 62 +++ e2e/package.json | 1 + e2e/src/api/specs/asset.e2e-spec.ts | 417 ++++++++++++++++++--- e2e/src/api/specs/audit.e2e-spec.ts | 4 +- e2e/src/api/specs/trash.e2e-spec.ts | 2 +- e2e/src/setup.ts | 8 +- e2e/src/utils.ts | 77 +++- server/e2e/jobs/specs/formats.e2e-spec.ts | 158 -------- server/e2e/jobs/specs/metadata.e2e-spec.ts | 102 ----- 9 files changed, 509 insertions(+), 322 deletions(-) delete mode 100644 server/e2e/jobs/specs/formats.e2e-spec.ts delete mode 100644 server/e2e/jobs/specs/metadata.e2e-spec.ts diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 649893ec89..edf5a1a09d 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -17,6 +17,7 @@ "@types/pg": "^8.11.0", "@types/supertest": "^6.0.2", "@vitest/coverage-v8": "^1.3.0", + "exiftool-vendored": "^24.5.0", "luxon": "^3.4.4", "pg": "^8.11.3", "socket.io-client": "^4.7.4", @@ -594,6 +595,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@photostructure/tz-lookup": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-9.0.2.tgz", + "integrity": "sha512-H8+tTt7ilJNkFyb+QgPnLEGUjQzGwiMb9n7lwRZNBgSKL3VZs9AkjI1E//FcwPjNafwAH932U92+xTqJiF3Bbw==", + "dev": true + }, "node_modules/@playwright/test": { "version": "1.41.2", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.41.2.tgz", @@ -1074,6 +1081,15 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/batch-cluster": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/batch-cluster/-/batch-cluster-13.0.0.tgz", + "integrity": "sha512-EreW0Vi8TwovhYUHBXXRA5tthuU2ynGsZFlboyMJHCCUXYa2AjgwnE3ubBOJs2xJLcuXFJbi6c/8pH5+FVj8Og==", + "dev": true, + "engines": { + "node": ">=14" + } + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -1391,6 +1407,43 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/exiftool-vendored": { + "version": "24.5.0", + "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-24.5.0.tgz", + "integrity": "sha512-uLGYfeshak3mYn2ucCsebXfNFdOpeAULlMb84wiJv+4B236n+ypgK/vr8bJgAcsIPSRJXFSz9WonvjjQYYqR3w==", + "dev": true, + "dependencies": { + "@photostructure/tz-lookup": "^9.0.1", + "@types/luxon": "^3.4.2", + "batch-cluster": "^13.0.0", + "he": "^1.2.0", + "luxon": "^3.4.4" + }, + "optionalDependencies": { + "exiftool-vendored.exe": "12.76.0", + "exiftool-vendored.pl": "12.76.0" + } + }, + "node_modules/exiftool-vendored.exe": { + "version": "12.76.0", + "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.76.0.tgz", + "integrity": "sha512-lbKPPs31qpjnhFiMRaVxJX+iNcJ+p0NrRSFLHHaX6KTsfMba6e5i6NykSvU3wMiafzUTef1Fen3XQ+8n1tjjNw==", + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/exiftool-vendored.pl": { + "version": "12.76.0", + "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.76.0.tgz", + "integrity": "sha512-4DxqgnvL71YziVoY27ZMgVfLAWDH3pQLljuV5+ffTnTPvz/BWeV+/bVFwRvDqCD3lkCWds0YfVcsycfJgbQ5fA==", + "dev": true, + "optional": true, + "os": [ + "!win32" + ] + }, "node_modules/fast-safe-stringify": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", @@ -1584,6 +1637,15 @@ "node": ">= 0.4" } }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "bin": { + "he": "bin/he" + } + }, "node_modules/hexoid": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz", diff --git a/e2e/package.json b/e2e/package.json index 7bbdfd1d9d..26a1d7ef3a 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -21,6 +21,7 @@ "@types/pg": "^8.11.0", "@types/supertest": "^6.0.2", "@vitest/coverage-v8": "^1.3.0", + "exiftool-vendored": "^24.5.0", "luxon": "^3.4.4", "pg": "^8.11.3", "socket.io-client": "^4.7.4", diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index db1821260b..e1f4450312 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -1,16 +1,39 @@ import { AssetFileUploadResponseDto, AssetResponseDto, + AssetTypeEnum, LoginResponseDto, SharedLinkType, } from '@immich/sdk'; +import { exiftool } from 'exiftool-vendored'; import { DateTime } from 'luxon'; +import { createHash } from 'node:crypto'; +import { readFile, writeFile } from 'node:fs/promises'; +import { basename, join } from 'node:path'; import { Socket } from 'socket.io-client'; import { createUserDto, uuidDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; -import { apiUtils, app, dbUtils } from 'src/utils'; +import { + apiUtils, + app, + dbUtils, + tempDir, + testAssetDir, + wsUtils, +} from 'src/utils'; import request from 'supertest'; -import { beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const locationAssetFilepath = `${testAssetDir}/metadata/gps-position/thompson-springs.jpg`; + +const sha1 = (bytes: Buffer) => + createHash('sha1').update(bytes).digest('base64'); + +const readTags = async (bytes: Buffer, filename: string) => { + const filepath = join(tempDir, filename); + await writeFile(filepath, bytes); + return exiftool.read(filepath); +}; const today = DateTime.fromObject({ year: 2023, @@ -24,25 +47,36 @@ describe('/asset', () => { let user1: LoginResponseDto; let user2: LoginResponseDto; let userStats: LoginResponseDto; - let asset1: AssetFileUploadResponseDto; - let asset2: AssetFileUploadResponseDto; - let asset3: AssetFileUploadResponseDto; - let asset4: AssetFileUploadResponseDto; // user2 asset - let asset5: AssetFileUploadResponseDto; - let asset6: AssetFileUploadResponseDto; + let user1Assets: AssetFileUploadResponseDto[]; + let user2Assets: AssetFileUploadResponseDto[]; + let assetLocation: AssetFileUploadResponseDto; let ws: Socket; beforeAll(async () => { apiUtils.setup(); await dbUtils.reset(); admin = await apiUtils.adminSetup({ onboarding: false }); - [user1, user2, userStats] = await Promise.all([ + + [ws, user1, user2, userStats] = await Promise.all([ + wsUtils.connect(admin.accessToken), apiUtils.userSetup(admin.accessToken, createUserDto.user1), apiUtils.userSetup(admin.accessToken, createUserDto.user2), apiUtils.userSetup(admin.accessToken, createUserDto.user3), ]); - [asset1, asset2, asset3, asset4, asset5, asset6] = await Promise.all([ + // asset location + assetLocation = await apiUtils.createAsset( + admin.accessToken, + {}, + { + filename: 'thompson-springs.jpg', + bytes: await readFile(locationAssetFilepath), + }, + ); + + await wsUtils.waitForEvent({ event: 'upload', assetId: assetLocation.id }); + + user1Assets = await Promise.all([ apiUtils.createAsset(user1.accessToken), apiUtils.createAsset(user1.accessToken), apiUtils.createAsset( @@ -56,10 +90,13 @@ describe('/asset', () => { }, { filename: 'example.mp4' }, ), - apiUtils.createAsset(user2.accessToken), apiUtils.createAsset(user1.accessToken), apiUtils.createAsset(user1.accessToken), + ]); + user2Assets = await Promise.all([apiUtils.createAsset(user2.accessToken)]); + + await Promise.all([ // stats apiUtils.createAsset(userStats.accessToken), apiUtils.createAsset(userStats.accessToken, { isFavorite: true }), @@ -77,7 +114,14 @@ describe('/asset', () => { const person1 = await apiUtils.createPerson(user1.accessToken, { name: 'Test Person', }); - await dbUtils.createFace({ assetId: asset1.id, personId: person1.id }); + await dbUtils.createFace({ + assetId: user1Assets[0].id, + personId: person1.id, + }); + }, 30_000); + + afterAll(() => { + wsUtils.disconnect(ws); }); describe('GET /asset/:id', () => { @@ -99,7 +143,7 @@ describe('/asset', () => { it('should require access', async () => { const { status, body } = await request(app) - .get(`/asset/${asset4.id}`) + .get(`/asset/${user2Assets[0].id}`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(400); expect(body).toEqual(errorDto.noPermission); @@ -107,33 +151,33 @@ describe('/asset', () => { it('should get the asset info', async () => { const { status, body } = await request(app) - .get(`/asset/${asset1.id}`) + .get(`/asset/${user1Assets[0].id}`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); - expect(body).toMatchObject({ id: asset1.id }); + expect(body).toMatchObject({ id: user1Assets[0].id }); }); it('should work with a shared link', async () => { const sharedLink = await apiUtils.createSharedLink(user1.accessToken, { type: SharedLinkType.Individual, - assetIds: [asset1.id], + assetIds: [user1Assets[0].id], }); const { status, body } = await request(app).get( - `/asset/${asset1.id}?key=${sharedLink.key}`, + `/asset/${user1Assets[0].id}?key=${sharedLink.key}`, ); expect(status).toBe(200); - expect(body).toMatchObject({ id: asset1.id }); + expect(body).toMatchObject({ id: user1Assets[0].id }); }); it('should not send people data for shared links for un-authenticated users', async () => { const { status, body } = await request(app) - .get(`/asset/${asset1.id}`) + .get(`/asset/${user1Assets[0].id}`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toEqual(200); expect(body).toMatchObject({ - id: asset1.id, + id: user1Assets[0].id, isFavorite: false, people: [ { @@ -148,11 +192,11 @@ describe('/asset', () => { const sharedLink = await apiUtils.createSharedLink(user1.accessToken, { type: SharedLinkType.Individual, - assetIds: [asset1.id], + assetIds: [user1Assets[0].id], }); const data = await request(app).get( - `/asset/${asset1.id}?key=${sharedLink.key}`, + `/asset/${user1Assets[0].id}?key=${sharedLink.key}`, ); expect(data.status).toBe(200); expect(data.body).toMatchObject({ people: [] }); @@ -246,11 +290,11 @@ describe('/asset', () => { const assets: AssetResponseDto[] = body; expect(assets.length).toBe(1); expect(assets[0].ownerId).toBe(user1.userId); - // - // assets owned by user2 - expect(assets[0].id).not.toBe(asset4.id); + // assets owned by user1 - expect([asset1.id, asset2.id, asset3.id]).toContain(assets[0].id); + expect([user1Assets.map(({ id }) => id)]).toContain(assets[0].id); + // assets owned by user2 + expect([user1Assets.map(({ id }) => id)]).not.toContain(assets[0].id); }); it.each(Array(10))('should return 2 random assets', async () => { @@ -266,9 +310,9 @@ describe('/asset', () => { for (const asset of assets) { expect(asset.ownerId).toBe(user1.userId); // assets owned by user1 - expect([asset1.id, asset2.id, asset3.id]).toContain(asset.id); + expect([user1Assets.map(({ id }) => id)]).toContain(asset.id); // assets owned by user2 - expect(asset.id).not.toBe(asset4.id); + expect([user2Assets.map(({ id }) => id)]).not.toContain(asset.id); } }); @@ -280,7 +324,9 @@ describe('/asset', () => { .set('Authorization', `Bearer ${user2.accessToken}`); expect(status).toBe(200); - expect(body).toEqual([expect.objectContaining({ id: asset4.id })]); + expect(body).toEqual([ + expect.objectContaining({ id: user2Assets[0].id }), + ]); }, ); @@ -312,44 +358,50 @@ describe('/asset', () => { it('should require access', async () => { const { status, body } = await request(app) - .put(`/asset/${asset4.id}`) + .put(`/asset/${user2Assets[0].id}`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(400); expect(body).toEqual(errorDto.noPermission); }); it('should favorite an asset', async () => { - const before = await apiUtils.getAssetInfo(user1.accessToken, asset1.id); + const before = await apiUtils.getAssetInfo( + user1.accessToken, + user1Assets[0].id, + ); expect(before.isFavorite).toBe(false); const { status, body } = await request(app) - .put(`/asset/${asset1.id}`) + .put(`/asset/${user1Assets[0].id}`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ isFavorite: true }); - expect(body).toMatchObject({ id: asset1.id, isFavorite: true }); + expect(body).toMatchObject({ id: user1Assets[0].id, isFavorite: true }); expect(status).toEqual(200); }); it('should archive an asset', async () => { - const before = await apiUtils.getAssetInfo(user1.accessToken, asset1.id); + const before = await apiUtils.getAssetInfo( + user1.accessToken, + user1Assets[0].id, + ); expect(before.isArchived).toBe(false); const { status, body } = await request(app) - .put(`/asset/${asset1.id}`) + .put(`/asset/${user1Assets[0].id}`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ isArchived: true }); - expect(body).toMatchObject({ id: asset1.id, isArchived: true }); + expect(body).toMatchObject({ id: user1Assets[0].id, isArchived: true }); expect(status).toEqual(200); }); it('should update date time original', async () => { const { status, body } = await request(app) - .put(`/asset/${asset1.id}`) + .put(`/asset/${user1Assets[0].id}`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ dateTimeOriginal: '2023-11-19T18:11:00.000-07:00' }); expect(body).toMatchObject({ - id: asset1.id, + id: user1Assets[0].id, exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-20T01:11:00.000Z', }), @@ -371,7 +423,7 @@ describe('/asset', () => { { latitude: 12, longitude: 181 }, ]) { const { status, body } = await request(app) - .put(`/asset/${asset1.id}`) + .put(`/asset/${user1Assets[0].id}`) .send(test) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(400); @@ -381,12 +433,12 @@ describe('/asset', () => { it('should update gps data', async () => { const { status, body } = await request(app) - .put(`/asset/${asset1.id}`) + .put(`/asset/${user1Assets[0].id}`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ latitude: 12, longitude: 12 }); expect(body).toMatchObject({ - id: asset1.id, + id: user1Assets[0].id, exifInfo: expect.objectContaining({ latitude: 12, longitude: 12 }), }); expect(status).toEqual(200); @@ -394,11 +446,11 @@ describe('/asset', () => { it('should set the description', async () => { const { status, body } = await request(app) - .put(`/asset/${asset1.id}`) + .put(`/asset/${user1Assets[0].id}`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ description: 'Test asset description' }); expect(body).toMatchObject({ - id: asset1.id, + id: user1Assets[0].id, exifInfo: expect.objectContaining({ description: 'Test asset description', }), @@ -408,12 +460,12 @@ describe('/asset', () => { it('should return tagged people', async () => { const { status, body } = await request(app) - .put(`/asset/${asset1.id}`) + .put(`/asset/${user1Assets[0].id}`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ isFavorite: true }); expect(status).toEqual(200); expect(body).toMatchObject({ - id: asset1.id, + id: user1Assets[0].id, isFavorite: true, people: [ { @@ -478,4 +530,279 @@ describe('/asset', () => { expect(after.isTrashed).toBe(true); }); }); + + describe('POST /asset/upload', () => { + const tests = [ + { + input: 'formats/jpg/el_torcal_rocks.jpg', + expected: { + type: AssetTypeEnum.Image, + originalFileName: 'el_torcal_rocks', + resized: true, + exifInfo: { + dateTimeOriginal: '2012-08-05T11:39:59.000Z', + exifImageWidth: 512, + exifImageHeight: 341, + latitude: null, + longitude: null, + focalLength: 75, + iso: 200, + fNumber: 11, + exposureTime: '1/160', + fileSizeInByte: 53_493, + make: 'SONY', + model: 'DSLR-A550', + orientation: null, + description: 'SONY DSC', + }, + }, + }, + { + input: 'formats/heic/IMG_2682.heic', + expected: { + type: AssetTypeEnum.Image, + originalFileName: 'IMG_2682', + resized: true, + fileCreatedAt: '2019-03-21T16:04:22.348Z', + exifInfo: { + dateTimeOriginal: '2019-03-21T16:04:22.348Z', + exifImageWidth: 4032, + exifImageHeight: 3024, + latitude: 41.2203, + longitude: -96.071_625, + make: 'Apple', + model: 'iPhone 7', + lensModel: 'iPhone 7 back camera 3.99mm f/1.8', + fileSizeInByte: 880_703, + exposureTime: '1/887', + iso: 20, + focalLength: 3.99, + fNumber: 1.8, + timeZone: 'America/Chicago', + }, + }, + }, + { + input: 'formats/png/density_plot.png', + expected: { + type: AssetTypeEnum.Image, + originalFileName: 'density_plot', + resized: true, + exifInfo: { + exifImageWidth: 800, + exifImageHeight: 800, + latitude: null, + longitude: null, + fileSizeInByte: 25_408, + }, + }, + }, + { + input: 'formats/raw/Nikon/D80/glarus.nef', + expected: { + type: AssetTypeEnum.Image, + originalFileName: 'glarus', + resized: true, + fileCreatedAt: '2010-07-20T17:27:12.000Z', + exifInfo: { + make: 'NIKON CORPORATION', + model: 'NIKON D80', + exposureTime: '1/200', + fNumber: 10, + focalLength: 18, + iso: 100, + fileSizeInByte: 9_057_784, + dateTimeOriginal: '2010-07-20T17:27:12.000Z', + latitude: null, + longitude: null, + orientation: '1', + }, + }, + }, + { + input: 'formats/raw/Nikon/D700/philadelphia.nef', + expected: { + type: AssetTypeEnum.Image, + originalFileName: 'philadelphia', + resized: true, + fileCreatedAt: '2016-09-22T22:10:29.060Z', + exifInfo: { + make: 'NIKON CORPORATION', + model: 'NIKON D700', + exposureTime: '1/400', + fNumber: 11, + focalLength: 85, + iso: 200, + fileSizeInByte: 15_856_335, + dateTimeOriginal: '2016-09-22T22:10:29.060Z', + latitude: null, + longitude: null, + orientation: '1', + timeZone: 'UTC-5', + }, + }, + }, + ]; + + for (const { input, expected } of tests) { + it(`should generate a thumbnail for ${input}`, async () => { + const filepath = join(testAssetDir, input); + const { id, duplicate } = await apiUtils.createAsset( + admin.accessToken, + {}, + { bytes: await readFile(filepath), filename: basename(filepath) }, + ); + + expect(duplicate).toBe(false); + + await wsUtils.waitForEvent({ event: 'upload', assetId: id }); + + const asset = await apiUtils.getAssetInfo(admin.accessToken, id); + + expect(asset.exifInfo).toBeDefined(); + expect(asset.exifInfo).toMatchObject(expected.exifInfo); + expect(asset).toMatchObject(expected); + }); + } + + it('should handle a duplicate', async () => { + const filepath = 'formats/jpeg/el_torcal_rocks.jpeg'; + const { duplicate } = await apiUtils.createAsset( + admin.accessToken, + {}, + { + bytes: await readFile(join(testAssetDir, filepath)), + filename: basename(filepath), + }, + ); + + expect(duplicate).toBe(true); + }); + + // These hashes were created by copying the image files to a Samsung phone, + // exporting the video from Samsung's stock Gallery app, and hashing them locally. + // This ensures that immich+exiftool are extracting the videos the same way Samsung does. + // DO NOT assume immich+exiftool are doing things correctly and just copy whatever hash it gives + // into the test here. + const motionTests = [ + { + filepath: 'formats/motionphoto/Samsung One UI 5.jpg', + checksum: 'fr14niqCq6N20HB8rJYEvpsUVtI=', + }, + { + filepath: 'formats/motionphoto/Samsung One UI 6.jpg', + checksum: 'lT9Uviw/FFJYCjfIxAGPTjzAmmw=', + }, + { + filepath: 'formats/motionphoto/Samsung One UI 6.heic', + checksum: '/ejgzywvgvzvVhUYVfvkLzFBAF0=', + }, + ]; + + for (const { filepath, checksum } of motionTests) { + it(`should extract motionphoto video from ${filepath}`, async () => { + const response = await apiUtils.createAsset( + admin.accessToken, + {}, + { + bytes: await readFile(join(testAssetDir, filepath)), + filename: basename(filepath), + }, + ); + + await wsUtils.waitForEvent({ event: 'upload', assetId: response.id }); + + expect(response.duplicate).toBe(false); + + const asset = await apiUtils.getAssetInfo( + admin.accessToken, + response.id, + ); + expect(asset.livePhotoVideoId).toBeDefined(); + + const video = await apiUtils.getAssetInfo( + admin.accessToken, + asset.livePhotoVideoId as string, + ); + expect(video.checksum).toStrictEqual(checksum); + }); + } + }); + + describe('GET /asset/thumbnail/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get( + `/asset/thumbnail/${assetLocation.id}`, + ); + + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should not include gps data for webp thumbnails', async () => { + const { status, body, type } = await request(app) + .get(`/asset/thumbnail/${assetLocation.id}?format=WEBP`) + .set('Authorization', `Bearer ${admin.accessToken}`); + + await wsUtils.waitForEvent({ + event: 'upload', + assetId: assetLocation.id, + }); + + expect(status).toBe(200); + expect(body).toBeDefined(); + expect(type).toBe('image/webp'); + + const exifData = await readTags(body, 'thumbnail.webp'); + expect(exifData).not.toHaveProperty('GPSLongitude'); + expect(exifData).not.toHaveProperty('GPSLatitude'); + }); + + it('should not include gps data for jpeg thumbnails', async () => { + const { status, body, type } = await request(app) + .get(`/asset/thumbnail/${assetLocation.id}?format=JPEG`) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toBeDefined(); + expect(type).toBe('image/jpeg'); + + const exifData = await readTags(body, 'thumbnail.jpg'); + expect(exifData).not.toHaveProperty('GPSLongitude'); + expect(exifData).not.toHaveProperty('GPSLatitude'); + }); + }); + + describe('GET /asset/file/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get( + `/asset/thumbnail/${assetLocation.id}`, + ); + + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should download the original', async () => { + const { status, body, type } = await request(app) + .get(`/asset/file/${assetLocation.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toBeDefined(); + expect(type).toBe('image/jpeg'); + + const asset = await apiUtils.getAssetInfo( + admin.accessToken, + assetLocation.id, + ); + + const original = await readFile(locationAssetFilepath); + const originalChecksum = sha1(original); + const downloadChecksum = sha1(body); + + expect(originalChecksum).toBe(downloadChecksum); + expect(downloadChecksum).toBe(asset.checksum); + }); + }); }); diff --git a/e2e/src/api/specs/audit.e2e-spec.ts b/e2e/src/api/specs/audit.e2e-spec.ts index 073106e728..0bc8e6b173 100644 --- a/e2e/src/api/specs/audit.e2e-spec.ts +++ b/e2e/src/api/specs/audit.e2e-spec.ts @@ -29,14 +29,14 @@ describe('/audit', () => { await Promise.all([ deleteAssets( { assetBulkDeleteDto: { ids: [trashedAsset.id] } }, - { headers: asBearerAuth(admin.accessToken) } + { headers: asBearerAuth(admin.accessToken) }, ), updateAsset( { id: archivedAsset.id, updateAssetDto: { isArchived: true }, }, - { headers: asBearerAuth(admin.accessToken) } + { headers: asBearerAuth(admin.accessToken) }, ), ]); diff --git a/e2e/src/api/specs/trash.e2e-spec.ts b/e2e/src/api/specs/trash.e2e-spec.ts index 2de838f981..cb4a8b9dd1 100644 --- a/e2e/src/api/specs/trash.e2e-spec.ts +++ b/e2e/src/api/specs/trash.e2e-spec.ts @@ -44,7 +44,7 @@ describe('/trash', () => { .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(204); - await wsUtils.once(ws, 'on_asset_delete'); + await wsUtils.waitForEvent({ event: 'delete', assetId }); const after = await getAllAssets( {}, diff --git a/e2e/src/setup.ts b/e2e/src/setup.ts index b560a2bbb1..04e8d79ac5 100644 --- a/e2e/src/setup.ts +++ b/e2e/src/setup.ts @@ -2,25 +2,25 @@ import { spawn, exec } from 'child_process'; export default async () => { let _resolve: () => unknown; - const promise = new Promise((resolve) => (_resolve = resolve)); + const ready = new Promise((resolve) => (_resolve = resolve)); const child = spawn('docker', ['compose', 'up'], { stdio: 'pipe' }); child.stdout.on('data', (data) => { const input = data.toString(); console.log(input); - if (input.includes('Immich Server is listening')) { + if (input.includes('Immich Microservices is listening')) { _resolve(); } }); child.stderr.on('data', (data) => console.log(data.toString())); - await promise; + await ready; return async () => { await new Promise((resolve) => - exec('docker compose down', () => resolve()) + exec('docker compose down', () => resolve()), ); }; }; diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 428c88b454..4261e8f67d 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -1,5 +1,6 @@ import { AssetFileUploadResponseDto, + AssetResponseDto, CreateAlbumDto, CreateAssetDto, CreateUserDto, @@ -19,10 +20,12 @@ import { updatePerson, } from '@immich/sdk'; import { BrowserContext } from '@playwright/test'; -import { exec, spawn } from 'child_process'; +import { exec, spawn } from 'node:child_process'; import { randomBytes } from 'node:crypto'; import { access } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; import path from 'node:path'; +import { EventEmitter } from 'node:stream'; import { promisify } from 'node:util'; import pg from 'pg'; import { io, type Socket } from 'socket.io-client'; @@ -40,6 +43,7 @@ const directoryExists = (directory: string) => // TODO move test assets into e2e/assets export const testAssetDir = path.resolve(`./../server/test/assets/`); +export const tempDir = tmpdir(); const serverContainerName = 'immich-e2e-server'; const mediaDir = '/usr/src/app/upload'; @@ -47,6 +51,7 @@ const dirs = [ `"${mediaDir}/thumbs"`, `"${mediaDir}/upload"`, `"${mediaDir}/library"`, + `"${mediaDir}/encoded-video"`, ].join(' '); if (!(await directoryExists(`${testAssetDir}/albums`))) { @@ -177,33 +182,85 @@ export interface AdminSetupOptions { onboarding?: boolean; } +export enum SocketEvent { + UPLOAD = 'upload', + DELETE = 'delete', +} + +export type EventType = 'upload' | 'delete'; +export interface WaitOptions { + event: EventType; + assetId: string; + timeout?: number; +} + +const events: Record> = { + upload: new Set(), + delete: new Set(), +}; + +const callbacks: Record void> = {}; + +const onEvent = ({ event, assetId }: { event: EventType; assetId: string }) => { + events[event].add(assetId); + const callback = callbacks[assetId]; + if (callback) { + callback(); + delete callbacks[assetId]; + } +}; + export const wsUtils = { connect: async (accessToken: string) => { const websocket = io('http://127.0.0.1:2283', { path: '/api/socket.io', transports: ['websocket'], extraHeaders: { Authorization: `Bearer ${accessToken}` }, - autoConnect: false, + autoConnect: true, forceNew: true, }); return new Promise((resolve) => { - websocket.on('connect', () => resolve(websocket)); - websocket.connect(); + websocket + .on('connect', () => resolve(websocket)) + .on('on_upload_success', (data: AssetResponseDto) => + onEvent({ event: 'upload', assetId: data.id }), + ) + .on('on_asset_delete', (assetId: string) => + onEvent({ event: 'delete', assetId }), + ) + .connect(); }); }, disconnect: (ws: Socket) => { if (ws?.connected) { ws.disconnect(); } + + for (const set of Object.values(events)) { + set.clear(); + } }, - once: (ws: Socket, event: string): Promise => { - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => reject(new Error('Timeout')), 4000); - ws.once(event, (data: T) => { + waitForEvent: async ({ + event, + assetId, + timeout: ms, + }: WaitOptions): Promise => { + const set = events[event]; + if (set.has(assetId)) { + return; + } + + return new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error(`Timed out waiting for ${event} event`)), + ms || 5000, + ); + + callbacks[assetId] = () => { clearTimeout(timeout); - resolve(data); - }); + resolve(); + }; }); }, }; diff --git a/server/e2e/jobs/specs/formats.e2e-spec.ts b/server/e2e/jobs/specs/formats.e2e-spec.ts deleted file mode 100644 index c8b14d588a..0000000000 --- a/server/e2e/jobs/specs/formats.e2e-spec.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { LoginResponseDto } from '@app/domain'; -import { AssetType } from '@app/infra/entities'; -import { readFile } from 'node:fs/promises'; -import { basename, join } from 'node:path'; -import { IMMICH_TEST_ASSET_PATH, testApp } from '../../../src/test-utils/utils'; -import { api } from '../../client'; - -const JPEG = { - type: AssetType.IMAGE, - originalFileName: 'el_torcal_rocks', - resized: true, - exifInfo: { - dateTimeOriginal: '2012-08-05T11:39:59.000Z', - exifImageWidth: 512, - exifImageHeight: 341, - latitude: null, - longitude: null, - focalLength: 75, - iso: 200, - fNumber: 11, - exposureTime: '1/160', - fileSizeInByte: 53_493, - make: 'SONY', - model: 'DSLR-A550', - orientation: null, - description: 'SONY DSC', - }, -}; - -const tests = [ - { input: 'formats/jpg/el_torcal_rocks.jpg', expected: JPEG }, - { input: 'formats/jpeg/el_torcal_rocks.jpeg', expected: JPEG }, - { - input: 'formats/heic/IMG_2682.heic', - expected: { - type: AssetType.IMAGE, - originalFileName: 'IMG_2682', - resized: true, - fileCreatedAt: '2019-03-21T16:04:22.348Z', - exifInfo: { - dateTimeOriginal: '2019-03-21T16:04:22.348Z', - exifImageWidth: 4032, - exifImageHeight: 3024, - latitude: 41.2203, - longitude: -96.071_625, - make: 'Apple', - model: 'iPhone 7', - lensModel: 'iPhone 7 back camera 3.99mm f/1.8', - fileSizeInByte: 880_703, - exposureTime: '1/887', - iso: 20, - focalLength: 3.99, - fNumber: 1.8, - timeZone: 'America/Chicago', - }, - }, - }, - { - input: 'formats/png/density_plot.png', - expected: { - type: AssetType.IMAGE, - originalFileName: 'density_plot', - resized: true, - exifInfo: { - exifImageWidth: 800, - exifImageHeight: 800, - latitude: null, - longitude: null, - fileSizeInByte: 25_408, - }, - }, - }, - { - input: 'formats/raw/Nikon/D80/glarus.nef', - expected: { - type: AssetType.IMAGE, - originalFileName: 'glarus', - resized: true, - fileCreatedAt: '2010-07-20T17:27:12.000Z', - exifInfo: { - make: 'NIKON CORPORATION', - model: 'NIKON D80', - exposureTime: '1/200', - fNumber: 10, - focalLength: 18, - iso: 100, - fileSizeInByte: 9_057_784, - dateTimeOriginal: '2010-07-20T17:27:12.000Z', - latitude: null, - longitude: null, - orientation: '1', - }, - }, - }, - { - input: 'formats/raw/Nikon/D700/philadelphia.nef', - expected: { - type: AssetType.IMAGE, - originalFileName: 'philadelphia', - resized: true, - fileCreatedAt: '2016-09-22T22:10:29.060Z', - exifInfo: { - make: 'NIKON CORPORATION', - model: 'NIKON D700', - exposureTime: '1/400', - fNumber: 11, - focalLength: 85, - iso: 200, - fileSizeInByte: 15_856_335, - dateTimeOriginal: '2016-09-22T22:10:29.060Z', - latitude: null, - longitude: null, - orientation: '1', - timeZone: 'UTC-5', - }, - }, - }, -]; - -describe(`Format (e2e)`, () => { - let server: any; - let admin: LoginResponseDto; - - beforeAll(async () => { - const app = await testApp.create(); - server = app.getHttpServer(); - }); - - beforeEach(async () => { - await testApp.reset(); - await api.authApi.adminSignUp(server); - admin = await api.authApi.adminLogin(server); - }); - - afterAll(async () => { - await testApp.teardown(); - }); - - for (const { input, expected } of tests) { - it(`should generate a thumbnail for ${input}`, async () => { - const filepath = join(IMMICH_TEST_ASSET_PATH, input); - const content = await readFile(filepath); - await api.assetApi.upload(server, admin.accessToken, 'test-device-id', { - content, - filename: basename(filepath), - }); - const assets = await api.assetApi.getAllAssets(server, admin.accessToken); - - expect(assets).toHaveLength(1); - - const asset = assets[0]; - - expect(asset.exifInfo).toBeDefined(); - expect(asset.exifInfo).toMatchObject(expected.exifInfo); - expect(asset).toMatchObject(expected); - }); - } -}); diff --git a/server/e2e/jobs/specs/metadata.e2e-spec.ts b/server/e2e/jobs/specs/metadata.e2e-spec.ts deleted file mode 100644 index 5eb75fee2d..0000000000 --- a/server/e2e/jobs/specs/metadata.e2e-spec.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { AssetResponseDto, LoginResponseDto } from '@app/domain'; -import { AssetController } from '@app/immich'; -import { exiftool } from 'exiftool-vendored'; -import { readFile, writeFile } from 'fs/promises'; -import { - IMMICH_TEST_ASSET_PATH, - IMMICH_TEST_ASSET_TEMP_PATH, - db, - restoreTempFolder, - testApp, -} from '../../../src/test-utils/utils'; -import { api } from '../../client'; - -describe(`${AssetController.name} (e2e)`, () => { - let server: any; - let admin: LoginResponseDto; - - beforeAll(async () => { - server = (await testApp.create()).getHttpServer(); - }); - - beforeEach(async () => { - await testApp.reset(); - await restoreTempFolder(); - await api.authApi.adminSignUp(server); - admin = await api.authApi.adminLogin(server); - }); - - afterAll(async () => { - await testApp.teardown(); - await restoreTempFolder(); - }); - - describe('should strip metadata of', () => { - let assetWithLocation: AssetResponseDto; - - beforeEach(async () => { - const fileContent = await readFile(`${IMMICH_TEST_ASSET_PATH}/metadata/gps-position/thompson-springs.jpg`); - - await api.assetApi.upload(server, admin.accessToken, 'test-asset-id', { content: fileContent }); - - const assets = await api.assetApi.getAllAssets(server, admin.accessToken); - - expect(assets).toHaveLength(1); - assetWithLocation = assets[0]; - - expect(assetWithLocation).toEqual( - expect.objectContaining({ - exifInfo: expect.objectContaining({ latitude: 39.115, longitude: -108.400968333333 }), - }), - ); - }); - - it('small webp thumbnails', async () => { - const assetId = assetWithLocation.id; - - const thumbnail = await api.assetApi.getWebpThumbnail(server, admin.accessToken, assetId); - - await writeFile(`${IMMICH_TEST_ASSET_TEMP_PATH}/thumbnail.webp`, thumbnail); - - const exifData = await exiftool.read(`${IMMICH_TEST_ASSET_TEMP_PATH}/thumbnail.webp`); - - expect(exifData).not.toHaveProperty('GPSLongitude'); - expect(exifData).not.toHaveProperty('GPSLatitude'); - }); - - it('large jpeg thumbnails', async () => { - const assetId = assetWithLocation.id; - - const thumbnail = await api.assetApi.getJpegThumbnail(server, admin.accessToken, assetId); - - await writeFile(`${IMMICH_TEST_ASSET_TEMP_PATH}/thumbnail.jpg`, thumbnail); - - const exifData = await exiftool.read(`${IMMICH_TEST_ASSET_TEMP_PATH}/thumbnail.jpg`); - - expect(exifData).not.toHaveProperty('GPSLongitude'); - expect(exifData).not.toHaveProperty('GPSLatitude'); - }); - }); - - describe.each([ - // These hashes were created by copying the image files to a Samsung phone, - // exporting the video from Samsung's stock Gallery app, and hashing them locally. - // This ensures that immich+exiftool are extracting the videos the same way Samsung does. - // DO NOT assume immich+exiftool are doing things correctly and just copy whatever hash it gives - // into the test here. - ['Samsung One UI 5.jpg', 'fr14niqCq6N20HB8rJYEvpsUVtI='], - ['Samsung One UI 6.jpg', 'lT9Uviw/FFJYCjfIxAGPTjzAmmw='], - ['Samsung One UI 6.heic', '/ejgzywvgvzvVhUYVfvkLzFBAF0='], - ])('should extract motionphoto video', (file, checksum) => { - it(`with checksum ${checksum} from ${file}`, async () => { - const fileContent = await readFile(`${IMMICH_TEST_ASSET_PATH}/formats/motionphoto/${file}`); - - const response = await api.assetApi.upload(server, admin.accessToken, 'test-asset-id', { content: fileContent }); - const asset = await api.assetApi.get(server, admin.accessToken, response.id); - expect(asset).toHaveProperty('livePhotoVideoId'); - const video = await api.assetApi.get(server, admin.accessToken, asset.livePhotoVideoId as string); - - expect(video.checksum).toStrictEqual(checksum); - }); - }); -}); From c88184673a70ac3a1d1a2488f59fcdf0623b12a3 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Wed, 28 Feb 2024 13:25:08 +0100 Subject: [PATCH 05/13] fix(web): keep notifications in view when scrolling (#7493) --- .../shared-components/notification/notification-list.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/components/shared-components/notification/notification-list.svelte b/web/src/lib/components/shared-components/notification/notification-list.svelte index af795072a8..bf8d93d5f2 100644 --- a/web/src/lib/components/shared-components/notification/notification-list.svelte +++ b/web/src/lib/components/shared-components/notification/notification-list.svelte @@ -10,7 +10,7 @@ {#if $notificationList.length > 0} -
+
{#each $notificationList as notificationInfo (notificationInfo.id)}
From 784d92dbb3bc3fdc11261920f65c88dd42b1c8ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20S=C3=A9hier?= Date: Wed, 28 Feb 2024 16:15:32 +0100 Subject: [PATCH 06/13] chore(deployment): add explicit registry to docker image names (#7496) --- docker/docker-compose.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index f1d16f7e6b..6b51e01f19 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -60,12 +60,12 @@ services: redis: container_name: immich_redis - image: redis:6.2-alpine@sha256:51d6c56749a4243096327e3fb964a48ed92254357108449cb6e23999c37773c5 + image: registry.hub.docker.com/library/redis:6.2-alpine@sha256:51d6c56749a4243096327e3fb964a48ed92254357108449cb6e23999c37773c5 restart: always database: container_name: immich_postgres - image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0 + image: registry.hub.docker.com/tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0 environment: POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_USER: ${DB_USERNAME} From e4f32a045d8003f9d588cf110cd9a02d5b68a874 Mon Sep 17 00:00:00 2001 From: Jonathan Jogenfors Date: Wed, 28 Feb 2024 21:20:10 +0100 Subject: [PATCH 07/13] chore: remove watcher polling option (#7480) * remove watcher polling * fix lint * add db migration --- docs/docs/features/libraries.md | 5 +---- .../doc/SystemConfigLibraryWatchDto.md | 2 -- .../system_config_library_watch_dto.dart | 22 +++---------------- .../system_config_library_watch_dto_test.dart | 10 --------- open-api/immich-openapi-specs.json | 10 +-------- open-api/typescript-sdk/axios-client/api.ts | 12 ---------- open-api/typescript-sdk/fetch-client.ts | 2 -- server/src/domain/library/library.service.ts | 9 +------- .../dto/system-config-library.dto.ts | 11 ---------- .../system-config/system-config.core.ts | 2 -- .../system-config.service.spec.ts | 2 -- .../infra/entities/system-config.entity.ts | 4 ---- ...0004123-RemoveLibraryWatchPollingOption.ts | 12 ++++++++++ .../library-settings/library-settings.svelte | 22 ------------------- 14 files changed, 18 insertions(+), 107 deletions(-) create mode 100644 server/src/infra/migrations/1709150004123-RemoveLibraryWatchPollingOption.ts diff --git a/docs/docs/features/libraries.md b/docs/docs/features/libraries.md index 995d652492..58dd707ea0 100644 --- a/docs/docs/features/libraries.md +++ b/docs/docs/features/libraries.md @@ -88,10 +88,7 @@ Some basic examples: This feature - currently hidden in the config file - is considered experimental and for advanced users only. If enabled, it will allow automatic watching of the filesystem which means new assets are automatically imported to Immich without needing to rescan. Deleted assets are, as always, marked as offline and can be removed with the "Remove offline files" button. -If your photos are on a network drive you will likely have to enable filesystem polling. The performance hit for polling large libraries is currently unknown, feel free to test this feature and report back. In addition to the boolean feature flag, the configuration file allows customization of the following parameters, please see the [chokidar documentation](https://github.com/paulmillr/chokidar?tab=readme-ov-file#performance) for reference. - -- `usePolling` (default: `false`). -- `interval`. (default: 10000). When using polling, this is how often (in milliseconds) the filesystem is polled. +If your photos are on a network drive, automatic file watching likely won't work. In that case, you will have to rely on a periodic library refresh to pull in your changes. ### Nightly job diff --git a/mobile/openapi/doc/SystemConfigLibraryWatchDto.md b/mobile/openapi/doc/SystemConfigLibraryWatchDto.md index baa0be6b08..43b975e21a 100644 --- a/mobile/openapi/doc/SystemConfigLibraryWatchDto.md +++ b/mobile/openapi/doc/SystemConfigLibraryWatchDto.md @@ -9,8 +9,6 @@ import 'package:openapi/api.dart'; Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- **enabled** | **bool** | | -**interval** | **int** | | -**usePolling** | **bool** | | [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/mobile/openapi/lib/model/system_config_library_watch_dto.dart b/mobile/openapi/lib/model/system_config_library_watch_dto.dart index 795fd15fd3..0bcb6f1771 100644 --- a/mobile/openapi/lib/model/system_config_library_watch_dto.dart +++ b/mobile/openapi/lib/model/system_config_library_watch_dto.dart @@ -14,37 +14,25 @@ class SystemConfigLibraryWatchDto { /// Returns a new [SystemConfigLibraryWatchDto] instance. SystemConfigLibraryWatchDto({ required this.enabled, - required this.interval, - required this.usePolling, }); bool enabled; - int interval; - - bool usePolling; - @override bool operator ==(Object other) => identical(this, other) || other is SystemConfigLibraryWatchDto && - other.enabled == enabled && - other.interval == interval && - other.usePolling == usePolling; + other.enabled == enabled; @override int get hashCode => // ignore: unnecessary_parenthesis - (enabled.hashCode) + - (interval.hashCode) + - (usePolling.hashCode); + (enabled.hashCode); @override - String toString() => 'SystemConfigLibraryWatchDto[enabled=$enabled, interval=$interval, usePolling=$usePolling]'; + String toString() => 'SystemConfigLibraryWatchDto[enabled=$enabled]'; Map toJson() { final json = {}; json[r'enabled'] = this.enabled; - json[r'interval'] = this.interval; - json[r'usePolling'] = this.usePolling; return json; } @@ -57,8 +45,6 @@ class SystemConfigLibraryWatchDto { return SystemConfigLibraryWatchDto( enabled: mapValueOfType(json, r'enabled')!, - interval: mapValueOfType(json, r'interval')!, - usePolling: mapValueOfType(json, r'usePolling')!, ); } return null; @@ -107,8 +93,6 @@ class SystemConfigLibraryWatchDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { 'enabled', - 'interval', - 'usePolling', }; } diff --git a/mobile/openapi/test/system_config_library_watch_dto_test.dart b/mobile/openapi/test/system_config_library_watch_dto_test.dart index 74ab53d652..19b5de05fe 100644 --- a/mobile/openapi/test/system_config_library_watch_dto_test.dart +++ b/mobile/openapi/test/system_config_library_watch_dto_test.dart @@ -21,16 +21,6 @@ void main() { // TODO }); - // int interval - test('to test the property `interval`', () async { - // TODO - }); - - // bool usePolling - test('to test the property `usePolling`', () async { - // TODO - }); - }); diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 44772e14a2..9f6b0ce607 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -9831,18 +9831,10 @@ "properties": { "enabled": { "type": "boolean" - }, - "interval": { - "type": "integer" - }, - "usePolling": { - "type": "boolean" } }, "required": [ - "enabled", - "interval", - "usePolling" + "enabled" ], "type": "object" }, diff --git a/open-api/typescript-sdk/axios-client/api.ts b/open-api/typescript-sdk/axios-client/api.ts index 154fc99693..0da04a8f2a 100644 --- a/open-api/typescript-sdk/axios-client/api.ts +++ b/open-api/typescript-sdk/axios-client/api.ts @@ -4401,18 +4401,6 @@ export interface SystemConfigLibraryWatchDto { * @memberof SystemConfigLibraryWatchDto */ 'enabled': boolean; - /** - * - * @type {number} - * @memberof SystemConfigLibraryWatchDto - */ - 'interval': number; - /** - * - * @type {boolean} - * @memberof SystemConfigLibraryWatchDto - */ - 'usePolling': boolean; } /** * diff --git a/open-api/typescript-sdk/fetch-client.ts b/open-api/typescript-sdk/fetch-client.ts index 5e97521274..fb0d04d2bb 100644 --- a/open-api/typescript-sdk/fetch-client.ts +++ b/open-api/typescript-sdk/fetch-client.ts @@ -835,8 +835,6 @@ export type SystemConfigLibraryScanDto = { }; export type SystemConfigLibraryWatchDto = { enabled: boolean; - interval: number; - usePolling: boolean; }; export type SystemConfigLibraryDto = { scan: SystemConfigLibraryScanDto; diff --git a/server/src/domain/library/library.service.ts b/server/src/domain/library/library.service.ts index 7c961963ec..6a5982abef 100644 --- a/server/src/domain/library/library.service.ts +++ b/server/src/domain/library/library.service.ts @@ -112,20 +112,13 @@ export class LibraryService extends EventEmitter { ignore: library.exclusionPatterns, }); - const config = await this.configCore.getConfig(); - const { usePolling, interval } = config.library.watch; - - this.logger.debug(`Settings for watcher: usePolling: ${usePolling}, interval: ${interval}`); - let _resolve: () => void; const ready$ = new Promise((resolve) => (_resolve = resolve)); this.watchers[id] = this.storageRepository.watch( library.importPaths, { - usePolling, - interval, - binaryInterval: interval, + usePolling: false, ignoreInitial: true, }, { diff --git a/server/src/domain/system-config/dto/system-config-library.dto.ts b/server/src/domain/system-config/dto/system-config-library.dto.ts index caf73498f2..fdbae600f6 100644 --- a/server/src/domain/system-config/dto/system-config-library.dto.ts +++ b/server/src/domain/system-config/dto/system-config-library.dto.ts @@ -1,12 +1,9 @@ import { validateCronExpression } from '@app/domain'; -import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsBoolean, - IsInt, IsNotEmpty, IsObject, - IsPositive, IsString, Validate, ValidateIf, @@ -38,14 +35,6 @@ export class SystemConfigLibraryScanDto { export class SystemConfigLibraryWatchDto { @IsBoolean() enabled!: boolean; - - @IsBoolean() - usePolling!: boolean; - - @IsInt() - @IsPositive() - @ApiProperty({ type: 'integer' }) - interval!: number; } export class SystemConfigLibraryDto { diff --git a/server/src/domain/system-config/system-config.core.ts b/server/src/domain/system-config/system-config.core.ts index eb661133b2..d32309955d 100644 --- a/server/src/domain/system-config/system-config.core.ts +++ b/server/src/domain/system-config/system-config.core.ts @@ -132,8 +132,6 @@ export const defaults = Object.freeze({ }, watch: { enabled: false, - usePolling: false, - interval: 10_000, }, }, server: { diff --git a/server/src/domain/system-config/system-config.service.spec.ts b/server/src/domain/system-config/system-config.service.spec.ts index ec0b4b8f4f..67a6418f44 100644 --- a/server/src/domain/system-config/system-config.service.spec.ts +++ b/server/src/domain/system-config/system-config.service.spec.ts @@ -136,8 +136,6 @@ const updatedConfig = Object.freeze({ }, watch: { enabled: false, - usePolling: false, - interval: 10_000, }, }, }); diff --git a/server/src/infra/entities/system-config.entity.ts b/server/src/infra/entities/system-config.entity.ts index 8307a0328e..edf4734359 100644 --- a/server/src/infra/entities/system-config.entity.ts +++ b/server/src/infra/entities/system-config.entity.ts @@ -51,8 +51,6 @@ export enum SystemConfigKey { LIBRARY_SCAN_CRON_EXPRESSION = 'library.scan.cronExpression', LIBRARY_WATCH_ENABLED = 'library.watch.enabled', - LIBRARY_WATCH_USE_POLLING = 'library.watch.usePolling', - LIBRARY_WATCH_INTERVAL = 'library.watch.interval', LOGGING_ENABLED = 'logging.enabled', LOGGING_LEVEL = 'logging.level', @@ -268,8 +266,6 @@ export interface SystemConfig { }; watch: { enabled: boolean; - usePolling: boolean; - interval: number; }; }; server: { diff --git a/server/src/infra/migrations/1709150004123-RemoveLibraryWatchPollingOption.ts b/server/src/infra/migrations/1709150004123-RemoveLibraryWatchPollingOption.ts new file mode 100644 index 0000000000..8b7ff3a675 --- /dev/null +++ b/server/src/infra/migrations/1709150004123-RemoveLibraryWatchPollingOption.ts @@ -0,0 +1,12 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RemoveLibraryWatchPollingOption1709150004123 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DELETE FROM "system_config" WHERE key = 'library.watch.usePolling'`); + await queryRunner.query(`DELETE FROM "system_config" WHERE key = 'library.watch.interval'`); + } + + public async down(): Promise { + // noop + } +} diff --git a/web/src/lib/components/admin-page/settings/library-settings/library-settings.svelte b/web/src/lib/components/admin-page/settings/library-settings/library-settings.svelte index 746db2c198..c1076abc54 100644 --- a/web/src/lib/components/admin-page/settings/library-settings/library-settings.svelte +++ b/web/src/lib/components/admin-page/settings/library-settings/library-settings.svelte @@ -42,28 +42,6 @@ subtitle="Watch external libraries for file changes" bind:checked={config.library.watch.enabled} /> - - - - - -

- Interval of filesystem polling, in milliseconds. Lower values will result in higher CPU usage. -

-
-
From 84fe41df31ebb5899b9045915bb5b71733c0a556 Mon Sep 17 00:00:00 2001 From: martin <74269598+martabal@users.noreply.github.com> Date: Wed, 28 Feb 2024 22:39:53 +0100 Subject: [PATCH 08/13] fix(web): re-render albums (#7403) * fix: re-render albums * fix: album description * fix: reactivity * fix album reactivity + components for title and description * only update AssetGrid when albumId changes * remove title and description bindings * remove console.log * chore: fix merge * pr feedback * pr feedback --------- Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> --- .../album-page/album-description.svelte | 45 +++ .../components/album-page/album-title.svelte | 42 +++ .../settings/setting-switch.svelte | 4 +- .../(user)/albums/[albumId]/+page.svelte | 297 +++++++----------- 4 files changed, 209 insertions(+), 179 deletions(-) create mode 100644 web/src/lib/components/album-page/album-description.svelte create mode 100644 web/src/lib/components/album-page/album-title.svelte diff --git a/web/src/lib/components/album-page/album-description.svelte b/web/src/lib/components/album-page/album-description.svelte new file mode 100644 index 0000000000..9e988ba75e --- /dev/null +++ b/web/src/lib/components/album-page/album-description.svelte @@ -0,0 +1,45 @@ + + +{#if isOwned} +