diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4558b90866..46b1baed7e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -392,6 +392,8 @@ jobs: node-version-file: './server/.nvmrc' cache: 'pnpm' cache-dependency-path: '**/pnpm-lock.yaml' + - name: Setup Mise + uses: immich-app/devtools/actions/use-mise@035e80a7d4355d5f087ffb95db9e4a0944c04e56 # use-mise-action-v1.1.3 - name: Run pnpm install run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile - name: Run medium tests diff --git a/.vscode/settings.json b/.vscode/settings.json index eeb80649ba..dbf9688b9b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -26,7 +26,8 @@ }, "[svelte]": { "editor.defaultFormatter": "svelte.svelte-vscode", - "editor.formatOnSave": true + "editor.formatOnSave": true, + "tailwindCSS.lint.suggestCanonicalClasses": "ignore" }, "[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode", diff --git a/e2e/src/responses.ts b/e2e/src/responses.ts index 2ec7aecb0e..5fd887c44b 100644 --- a/e2e/src/responses.ts +++ b/e2e/src/responses.ts @@ -28,6 +28,10 @@ export const errorDto = { badRequest: (message: any = null) => ({ message: message ?? expect.anything(), }), + validationError: (errors?: ReadonlyArray<{ path: ReadonlyArray; message: string }>) => ({ + message: 'Validation failed', + errors: errors ? expect.arrayContaining(errors.map((e) => expect.objectContaining(e))) : expect.any(Array), + }), noPermission: { message: expect.stringContaining('Not found or no'), }, @@ -37,9 +41,6 @@ export const errorDto = { alreadyHasAdmin: { message: 'The server already has an admin', }, - invalidEmail: { - message: ['email must be an email'], - }, }; export const signupResponseDto = { diff --git a/e2e/src/specs/server/api/library.e2e-spec.ts b/e2e/src/specs/server/api/library.e2e-spec.ts index 719436a66d..ccb594610c 100644 --- a/e2e/src/specs/server/api/library.e2e-spec.ts +++ b/e2e/src/specs/server/api/library.e2e-spec.ts @@ -110,7 +110,9 @@ describe('/libraries', () => { }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[importPaths] Array must have unique items'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['importPaths'], message: 'Array must have unique items' }]), + ); }); it('should not create an external library with duplicate exclusion patterns', async () => { @@ -125,7 +127,9 @@ describe('/libraries', () => { }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[exclusionPatterns] Array must have unique items'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['exclusionPatterns'], message: 'Array must have unique items' }]), + ); }); }); @@ -157,7 +161,9 @@ describe('/libraries', () => { .send({ name: '' }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[name] Too small: expected string to have >=1 characters'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['name'], message: 'Too small: expected string to have >=1 characters' }]), + ); }); it('should change the import paths', async () => { @@ -181,7 +187,9 @@ describe('/libraries', () => { .send({ importPaths: [''] }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[importPaths] Array items must not be empty'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['importPaths'], message: 'Array items must not be empty' }]), + ); }); it('should reject duplicate import paths', async () => { @@ -191,7 +199,9 @@ describe('/libraries', () => { .send({ importPaths: ['/path', '/path'] }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[importPaths] Array must have unique items'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['importPaths'], message: 'Array must have unique items' }]), + ); }); it('should change the exclusion pattern', async () => { @@ -215,7 +225,9 @@ describe('/libraries', () => { .send({ exclusionPatterns: ['**/*.jpg', '**/*.jpg'] }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[exclusionPatterns] Array must have unique items'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['exclusionPatterns'], message: 'Array must have unique items' }]), + ); }); it('should reject an empty exclusion pattern', async () => { @@ -225,7 +237,9 @@ describe('/libraries', () => { .send({ exclusionPatterns: [''] }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[exclusionPatterns] Array items must not be empty'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['exclusionPatterns'], message: 'Array items must not be empty' }]), + ); }); }); diff --git a/e2e/src/specs/server/api/map.e2e-spec.ts b/e2e/src/specs/server/api/map.e2e-spec.ts index c280deb134..86664b2dc4 100644 --- a/e2e/src/specs/server/api/map.e2e-spec.ts +++ b/e2e/src/specs/server/api/map.e2e-spec.ts @@ -109,7 +109,9 @@ describe('/map', () => { .get('/map/reverse-geocode?lon=123') .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[lat] Invalid input: expected number, received NaN'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['lat'], message: 'Invalid input: expected number, received NaN' }]), + ); }); it('should throw an error if a lat is not a number', async () => { @@ -117,7 +119,9 @@ describe('/map', () => { .get('/map/reverse-geocode?lat=abc&lon=123.456') .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[lat] Invalid input: expected number, received NaN'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['lat'], message: 'Invalid input: expected number, received NaN' }]), + ); }); it('should throw an error if a lat is out of range', async () => { @@ -125,7 +129,9 @@ describe('/map', () => { .get('/map/reverse-geocode?lat=91&lon=123.456') .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[lat] Too big: expected number to be <=90'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['lat'], message: 'Too big: expected number to be <=90' }]), + ); }); it('should throw an error if a lon is not provided', async () => { @@ -133,7 +139,9 @@ describe('/map', () => { .get('/map/reverse-geocode?lat=75') .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[lon] Invalid input: expected number, received NaN'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['lon'], message: 'Invalid input: expected number, received NaN' }]), + ); }); const reverseGeocodeTestCases = [ diff --git a/e2e/src/specs/server/api/oauth.e2e-spec.ts b/e2e/src/specs/server/api/oauth.e2e-spec.ts index 157fdfc84c..4bf4f197b1 100644 --- a/e2e/src/specs/server/api/oauth.e2e-spec.ts +++ b/e2e/src/specs/server/api/oauth.e2e-spec.ts @@ -105,7 +105,11 @@ describe(`/oauth`, () => { it(`should throw an error if a redirect uri is not provided`, async () => { const { status, body } = await request(app).post('/oauth/authorize').send({}); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[redirectUri] Invalid input: expected string, received undefined'])); + expect(body).toEqual( + errorDto.validationError([ + { path: ['redirectUri'], message: 'Invalid input: expected string, received undefined' }, + ]), + ); }); it('should return a redirect uri', async () => { @@ -164,13 +168,17 @@ describe(`/oauth`, () => { it(`should throw an error if a url is not provided`, async () => { const { status, body } = await request(app).post('/oauth/callback').send({}); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[url] Invalid input: expected string, received undefined'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['url'], message: 'Invalid input: expected string, received undefined' }]), + ); }); it(`should throw an error if the url is empty`, async () => { const { status, body } = await request(app).post('/oauth/callback').send({ url: '' }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[url] Too small: expected string to have >=1 characters'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['url'], message: 'Too small: expected string to have >=1 characters' }]), + ); }); it(`should throw an error if the state is not provided`, async () => { @@ -351,7 +359,7 @@ describe(`/oauth`, () => { const callbackParams = await loginWithOAuth('oauth-no-auto-register'); const { status, body } = await request(app).post('/oauth/callback').send(callbackParams); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest('User does not exist and auto registering is disabled.')); + expect(body).toEqual(errorDto.badRequest('OAuth authentication failed')); }); it('should link to an existing user by email', async () => { @@ -375,7 +383,11 @@ describe(`/oauth`, () => { it(`should throw an error if the logout_token is not provided`, async () => { const { status, body } = await request(app).post('/oauth/backchannel-logout').send({}); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[logout_token] Invalid input: expected string, received undefined'])); + expect(body).toEqual( + errorDto.validationError([ + { path: ['logout_token'], message: 'Invalid input: expected string, received undefined' }, + ]), + ); }); it(`should throw an error if an invalid logout token is provided`, async () => { diff --git a/e2e/src/specs/server/api/shared-link.e2e-spec.ts b/e2e/src/specs/server/api/shared-link.e2e-spec.ts index 1d069d0f54..8cdf2dc03c 100644 --- a/e2e/src/specs/server/api/shared-link.e2e-spec.ts +++ b/e2e/src/specs/server/api/shared-link.e2e-spec.ts @@ -341,7 +341,9 @@ describe('/shared-links', () => { .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest()); + expect(body).toEqual( + errorDto.validationError([{ path: [], message: 'Invalid input: expected object, received undefined' }]), + ); }); it('should require an asset/album id', async () => { diff --git a/e2e/src/specs/server/api/stack.e2e-spec.ts b/e2e/src/specs/server/api/stack.e2e-spec.ts index 91dd0d2a8e..76bf514dc8 100644 --- a/e2e/src/specs/server/api/stack.e2e-spec.ts +++ b/e2e/src/specs/server/api/stack.e2e-spec.ts @@ -41,7 +41,9 @@ describe('/stacks', () => { .send({ assetIds: [asset.id] }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest()); + expect(body).toEqual( + errorDto.validationError([{ path: ['assetIds'], message: 'Too small: expected array to have >=2 items' }]), + ); }); it('should require a valid id', async () => { @@ -51,7 +53,12 @@ describe('/stacks', () => { .send({ assetIds: [uuidDto.invalid, uuidDto.invalid] }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest()); + expect(body).toEqual( + errorDto.validationError([ + { path: ['assetIds', 0], message: 'Invalid UUID' }, + { path: ['assetIds', 1], message: 'Invalid UUID' }, + ]), + ); }); it('should require access', async () => { diff --git a/e2e/src/specs/server/api/tag.e2e-spec.ts b/e2e/src/specs/server/api/tag.e2e-spec.ts index 7b5a2f16de..d303a1e98d 100644 --- a/e2e/src/specs/server/api/tag.e2e-spec.ts +++ b/e2e/src/specs/server/api/tag.e2e-spec.ts @@ -309,7 +309,7 @@ describe('/tags', () => { .get(`/tags/${uuidDto.invalid}`) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID'])); + expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }])); }); it('should get tag details', async () => { @@ -427,7 +427,7 @@ describe('/tags', () => { .delete(`/tags/${uuidDto.invalid}`) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID'])); + expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }])); }); it('should delete a tag', async () => { diff --git a/e2e/src/specs/server/api/user-admin.e2e-spec.ts b/e2e/src/specs/server/api/user-admin.e2e-spec.ts index 6751b21e84..df6fea84bc 100644 --- a/e2e/src/specs/server/api/user-admin.e2e-spec.ts +++ b/e2e/src/specs/server/api/user-admin.e2e-spec.ts @@ -108,14 +108,20 @@ describe('/admin/users', () => { expect(body).toEqual(errorDto.forbidden); }); - for (const key of ['password', 'email', 'name', 'quotaSizeInBytes', 'shouldChangePassword', 'notify']) { + for (const [key, message] of [ + ['password', 'Invalid input: expected string, received null'], + ['email', 'Invalid input: expected email, received object'], + ['name', 'Invalid input: expected string, received null'], + ['shouldChangePassword', 'Invalid input: expected boolean, received null'], + ['notify', 'Invalid input: expected boolean, received null'], + ] as const) { it(`should not allow null ${key}`, async () => { const { status, body } = await request(app) .post(`/admin/users`) .set('Authorization', `Bearer ${admin.accessToken}`) .send({ ...createUserDto.user1, [key]: null }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest()); + expect(body).toEqual(errorDto.validationError([{ path: [key], message }])); }); } @@ -153,14 +159,19 @@ describe('/admin/users', () => { expect(body).toEqual(errorDto.forbidden); }); - for (const key of ['password', 'email', 'name', 'shouldChangePassword']) { + for (const [key, message] of [ + ['password', 'Invalid input: expected string, received null'], + ['email', 'Invalid input: expected email, received object'], + ['name', 'Invalid input: expected string, received null'], + ['shouldChangePassword', 'Invalid input: expected boolean, received null'], + ] as const) { it(`should not allow null ${key}`, async () => { const { status, body } = await request(app) .put(`/admin/users/${uuidDto.notFound}`) .set('Authorization', `Bearer ${admin.accessToken}`) .send({ [key]: null }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest()); + expect(body).toEqual(errorDto.validationError([{ path: [key], message }])); }); } diff --git a/e2e/src/specs/server/api/user.e2e-spec.ts b/e2e/src/specs/server/api/user.e2e-spec.ts index ee13a29c1b..8a2197efde 100644 --- a/e2e/src/specs/server/api/user.e2e-spec.ts +++ b/e2e/src/specs/server/api/user.e2e-spec.ts @@ -120,7 +120,7 @@ describe('/users', () => { .set('Authorization', `Bearer ${nonAdmin.accessToken}`); expect(status).toBe(400); - expect(body).toMatchObject(errorDto.badRequest('Email already in use by another account')); + expect(body).toMatchObject(errorDto.badRequest('Email is not available')); }); it('should update my email', async () => { @@ -179,7 +179,9 @@ describe('/users', () => { expect(status).toBe(400); expect(body).toEqual( - errorDto.badRequest(['[download.archiveSize] Invalid input: expected int, received number']), + errorDto.validationError([ + { path: ['download', 'archiveSize'], message: 'Invalid input: expected int, received number' }, + ]), ); }); @@ -207,7 +209,9 @@ describe('/users', () => { expect(status).toBe(400); expect(body).toEqual( - errorDto.badRequest(['[download.includeEmbeddedVideos] Invalid input: expected boolean, received number']), + errorDto.validationError([ + { path: ['download', 'includeEmbeddedVideos'], message: 'Invalid input: expected boolean, received number' }, + ]), ); }); diff --git a/e2e/src/ui/specs/timeline/timeline.e2e-spec.ts b/e2e/src/ui/specs/timeline/timeline.e2e-spec.ts index 5069a46a91..c2a3b8e724 100644 --- a/e2e/src/ui/specs/timeline/timeline.e2e-spec.ts +++ b/e2e/src/ui/specs/timeline/timeline.e2e-spec.ts @@ -304,7 +304,7 @@ test.describe('Timeline', () => { await page.keyboard.down('Shift'); await thumbnailUtils.withAssetId(page, assets[2].id).hover(); await expect( - thumbnailUtils.locator(page).locator('.absolute.top-0.h-full.w-full.bg-immich-primary.opacity-40'), + thumbnailUtils.locator(page).locator('.absolute.top-0.size-full.bg-immich-primary.opacity-40'), ).toHaveCount(3); await thumbnailUtils.selectButton(page, assets[2].id).click(); await page.keyboard.up('Shift'); diff --git a/e2e/test-assets b/e2e/test-assets index 0eac5a3738..6742055402 160000 --- a/e2e/test-assets +++ b/e2e/test-assets @@ -1 +1 @@ -Subproject commit 0eac5a37384c151be88381b41f9e28d8d59a4466 +Subproject commit 6742055402de1aa48f93d12ded7d18f4057f9d1f diff --git a/i18n/en.json b/i18n/en.json index add755c05d..cc30d9e350 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1761,6 +1761,7 @@ "play_original_video": "Play original video", "play_original_video_setting_description": "Prefer playback of original videos rather than transcoded videos. If original asset is not compatible it may not playback correctly.", "play_transcoded_video": "Play transcoded video", + "playback_speed": "Playback speed", "please_auth_to_access": "Please authenticate to access", "port": "Port", "preferences_settings_subtitle": "Manage the app's preferences", @@ -2436,6 +2437,7 @@ "workflows": "Workflows", "workflows_help_text": "Workflows automate actions on your assets based on triggers and filters", "wrong_pin_code": "Wrong PIN code", + "x_of_total": "{x}/{total}", "year": "Year", "years_ago": "{years, plural, one {# year} other {# years}} ago", "yes": "Yes", diff --git a/machine-learning/Dockerfile b/machine-learning/Dockerfile index 46c32f3d6a..c6f9f01675 100644 --- a/machine-learning/Dockerfile +++ b/machine-learning/Dockerfile @@ -68,7 +68,7 @@ ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \ RUN apt-get update && \ # Pascal support was dropped in 9.11 - apt-get install --no-install-recommends -yqq libcudnn9-cuda-12=9.10.2.21-1 && \ + apt-get install --no-install-recommends -yqq libcudnn9-cuda-12=9.10.2.21-1 tzdata && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* @@ -112,7 +112,7 @@ ARG RKNN_TOOLKIT_VERSION="v2.3.0" ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \ MACHINE_LEARNING_MODEL_ARENA=false -ADD --checksum=sha256:73993ed4b440460825f21611731564503cc1d5a0c123746477da6cd574f34885 "https://github.com/airockchip/rknn-toolkit2/raw/refs/tags/${RKNN_TOOLKIT_VERSION}/rknpu2/runtime/Linux/librknn_api/aarch64/librknnrt.so" /usr/lib/ +ADD --chmod=644 --checksum=sha256:73993ed4b440460825f21611731564503cc1d5a0c123746477da6cd574f34885 "https://github.com/airockchip/rknn-toolkit2/raw/refs/tags/${RKNN_TOOLKIT_VERSION}/rknpu2/runtime/Linux/librknn_api/aarch64/librknnrt.so" /usr/lib/ FROM prod-${DEVICE} AS prod diff --git a/mise.toml b/mise.toml index b7398a0e8d..7fa3473d62 100644 --- a/mise.toml +++ b/mise.toml @@ -22,10 +22,19 @@ opentofu = "1.11.6" java = "21.0.2" [tools."github:CQLabs/homebrew-dcm"] -version = "1.35.1" +version = "1.37.0" bin = "dcm" postinstall = "chmod +x $MISE_TOOL_INSTALL_PATH/dcm" +[tools."github:jellyfin/jellyfin-ffmpeg"] +version = "7.1.3-6" + +[tools."github:jellyfin/jellyfin-ffmpeg".platforms] +linux-x64 = { asset_pattern = "jellyfin-ffmpeg_*_portable_linux64-gpl.tar.xz" } +linux-arm64 = { asset_pattern = "jellyfin-ffmpeg_*_portable_linuxarm64-gpl.tar.xz" } +macos-x64 = { asset_pattern = "jellyfin-ffmpeg_*_portable_mac64-gpl.tar.xz" } +macos-arm64 = { asset_pattern = "jellyfin-ffmpeg_*_portable_macarm64-gpl.tar.xz" } + [settings] experimental = true pin = true diff --git a/mobile/.vscode/settings.json b/mobile/.vscode/settings.json index 051c18ce6a..517086e98a 100644 --- a/mobile/.vscode/settings.json +++ b/mobile/.vscode/settings.json @@ -1,5 +1,5 @@ { - "dart.flutterSdkPath": ".fvm/versions/3.41.7", + "dart.flutterSdkPath": ".fvm/versions/3.41.9", "dart.lineLength": 120, "[dart]": { "editor.rulers": [ diff --git a/mobile/dcm_global.yaml b/mobile/dcm_global.yaml index ffe77eede8..0518849062 100644 --- a/mobile/dcm_global.yaml +++ b/mobile/dcm_global.yaml @@ -1 +1 @@ -version: '>=1.29.0 <=1.36.0' +version: '>=1.29.0 <=1.37.0' diff --git a/mobile/mise.toml b/mobile/mise.toml index 6d6af62876..ed928f2445 100644 --- a/mobile/mise.toml +++ b/mobile/mise.toml @@ -1,5 +1,5 @@ [tools] -flutter = "3.41.7" +flutter = "3.41.9" [tools."github:CQLabs/homebrew-dcm"] version = "1.30.0" diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index e0e3c4ddc8..5f7aa3a928 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1989,4 +1989,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.11.0 <4.0.0" - flutter: "3.41.7" + flutter: "3.41.9" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 351d6869b3..8cb7eb17eb 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -6,7 +6,7 @@ version: 2.7.5+3046 environment: sdk: '>=3.11.0 <4.0.0' - flutter: 3.41.7 + flutter: 3.41.9 dependencies: async: ^2.13.1 diff --git a/open-api/typescript-sdk/src/fetch-errors.ts b/open-api/typescript-sdk/src/fetch-errors.ts index f21f0ed1c4..306710fb8b 100644 --- a/open-api/typescript-sdk/src/fetch-errors.ts +++ b/open-api/typescript-sdk/src/fetch-errors.ts @@ -1,9 +1,16 @@ import { HttpError } from '@oazapfts/runtime'; +export interface ApiValidationError { + code: string; + path: (string | number)[]; + message: string; +} + export interface ApiExceptionResponse { message: string; error?: string; statusCode: number; + errors?: ApiValidationError[]; } export interface ApiHttpError extends HttpError { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6de45bf746..c3f970ca51 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -804,6 +804,9 @@ importers: maplibre-gl: specifier: ^5.6.2 version: 5.24.0 + media-chrome: + specifier: ^4.19.0 + version: 4.19.0(react@19.2.5) pmtiles: specifier: ^4.3.0 version: 4.4.1 @@ -875,7 +878,7 @@ importers: specifier: 7.0.0 version: 7.0.0(svelte@5.55.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) '@tailwindcss/vite': - specifier: ^4.2.2 + specifier: ^4.2.4 version: 4.2.4(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) '@testing-library/jest-dom': specifier: ^6.4.2 @@ -919,6 +922,9 @@ importers: eslint-config-prettier: specifier: ^10.1.8 version: 10.1.8(eslint@10.2.1(jiti@2.6.1)) + eslint-plugin-better-tailwindcss: + specifier: ^4.5.0 + version: 4.5.0(eslint@10.2.1(jiti@2.6.1))(tailwindcss@4.2.4)(typescript@6.0.3) eslint-plugin-compat: specifier: ^7.0.0 version: 7.0.1(eslint@10.2.1(jiti@2.6.1)) @@ -956,7 +962,7 @@ importers: specifier: ^1.3.3 version: 1.6.0(svelte@5.55.2) tailwindcss: - specifier: ^4.2.2 + specifier: ^4.2.4 version: 4.2.4 typescript: specifier: ^6.0.0 @@ -2730,6 +2736,10 @@ packages: resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@eslint/css-tree@4.0.2': + resolution: {integrity: sha512-eqSkC3mka2tiqOuPZKqvxNJoRzpxMss3Np3Yqi4sW7nTTRCpTKB2hzrY4JRsi0ZP3QbVfp23sgEm7VCoOjesmw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@eslint/js@10.0.1': resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -5468,6 +5478,11 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + '@valibot/to-json-schema@1.6.0': + resolution: {integrity: sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A==} + peerDependencies: + valibot: ^1.3.0 + '@vercel/oidc@3.0.5': resolution: {integrity: sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw==} engines: {node: '>= 20'} @@ -6163,6 +6178,11 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + ce-la-react@0.3.2: + resolution: {integrity: sha512-QJ6k4lOD/btI08xG8jBPxRCGXvCnusGGkTsiXk0u3NqUu/W+BXRnFD4PYjwtqh8AWmGa5LDbGk0fLQsqr0nSMA==} + peerDependencies: + react: '>=17.0.0' + chai@5.3.3: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} @@ -7329,6 +7349,19 @@ packages: peerDependencies: eslint: '>=7.0.0' + eslint-plugin-better-tailwindcss@4.5.0: + resolution: {integrity: sha512-EBNTx6OJYaWv7uUxHWTy1fhiNz2rZVkoeOHZzAJFwWaEPideBf04CMshrJ7YntG0KQzadlbRhHKYr32q5aBX4w==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + oxlint: ^1.35.0 + tailwindcss: ^3.3.0 || ^4.1.17 + peerDependenciesMeta: + eslint: + optional: true + oxlint: + optional: true + eslint-plugin-compat@7.0.1: resolution: {integrity: sha512-wDID2fVIAfxV9R1uSkCn5HscnNu8yMxDF1IaQGyD1C6XuWwJbuaDgMOSkVgOom0LzY8z0fXXXCy7AQQTERQUvQ==} engines: {node: '>=18.x'} @@ -9052,6 +9085,12 @@ packages: mdn-data@2.0.30: resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + media-chrome@4.19.0: + resolution: {integrity: sha512-HWhDTwts+BSbdPkkB1VsJXp5kvL0IxY7xFT5tBwliM2+89kTPVTnHnev+9it2f9PweANjT/C8/C/S0PW9oyZbA==} + media-typer@0.3.0: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} @@ -11553,6 +11592,15 @@ packages: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} + tailwind-csstree@0.3.1: + resolution: {integrity: sha512-v147gLOR+E+9H4dNaP9rBeS/S/CTQJMRItlX9jLOXjdBGfSRauLwiz7LBCViaQmn6URXIlOdN6iMzSzOaeoUUw==} + engines: {node: '>=18.18'} + peerDependencies: + '@eslint/css': '>=1.0.0' + peerDependenciesMeta: + '@eslint/css': + optional: true + tailwind-merge@3.5.0: resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} @@ -12099,6 +12147,14 @@ packages: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true + valibot@1.3.1: + resolution: {integrity: sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + validator@13.15.35: resolution: {integrity: sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==} engines: {node: '>= 0.10'} @@ -15090,6 +15146,11 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 + '@eslint/css-tree@4.0.2': + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + '@eslint/js@10.0.1(eslint@10.2.1(jiti@2.6.1))': optionalDependencies: eslint: 10.2.1(jiti@2.6.1) @@ -17887,6 +17948,10 @@ snapshots: '@ungap/structured-clone@1.3.0': {} + '@valibot/to-json-schema@1.6.0(valibot@1.3.1(typescript@6.0.3))': + dependencies: + valibot: 1.3.1(typescript@6.0.3) + '@vercel/oidc@3.0.5': {} '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': @@ -17920,7 +17985,7 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/expect@3.2.4': dependencies: @@ -18709,6 +18774,10 @@ snapshots: ccount@2.0.1: {} + ce-la-react@0.3.2(react@19.2.5): + dependencies: + react: 19.2.5 + chai@5.3.3: dependencies: assertion-error: 2.0.1 @@ -19977,6 +20046,23 @@ snapshots: dependencies: eslint: 10.2.1(jiti@2.6.1) + eslint-plugin-better-tailwindcss@4.5.0(eslint@10.2.1(jiti@2.6.1))(tailwindcss@4.2.4)(typescript@6.0.3): + dependencies: + '@eslint/css-tree': 4.0.2 + '@valibot/to-json-schema': 1.6.0(valibot@1.3.1(typescript@6.0.3)) + enhanced-resolve: 5.21.0 + jiti: 2.6.1 + synckit: 0.11.12 + tailwind-csstree: 0.3.1 + tailwindcss: 4.2.4 + tsconfig-paths-webpack-plugin: 4.2.0 + valibot: 1.3.1(typescript@6.0.3) + optionalDependencies: + eslint: 10.2.1(jiti@2.6.1) + transitivePeerDependencies: + - '@eslint/css' + - typescript + eslint-plugin-compat@7.0.1(eslint@10.2.1(jiti@2.6.1)): dependencies: '@mdn/browser-compat-data': 6.1.5 @@ -22094,6 +22180,14 @@ snapshots: mdn-data@2.0.30: {} + mdn-data@2.27.1: {} + + media-chrome@4.19.0(react@19.2.5): + dependencies: + ce-la-react: 0.3.2(react@19.2.5) + transitivePeerDependencies: + - react + media-typer@0.3.0: {} media-typer@1.1.0: {} @@ -25150,6 +25244,8 @@ snapshots: tagged-tag@1.0.0: {} + tailwind-csstree@0.3.1: {} + tailwind-merge@3.5.0: {} tailwind-variants@3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.4): @@ -25749,6 +25845,10 @@ snapshots: uuid@8.3.2: {} + valibot@1.3.1(typescript@6.0.3): + optionalDependencies: + typescript: 6.0.3 + validator@13.15.35: {} value-equal@1.0.1: {} diff --git a/server/eslint.config.mjs b/server/eslint.config.mjs index f80dbb4691..bdbac34da6 100644 --- a/server/eslint.config.mjs +++ b/server/eslint.config.mjs @@ -48,6 +48,7 @@ export default typescriptEslint.config([ 'unicorn/import-style': 'off', 'unicorn/prefer-structured-clone': 'off', 'unicorn/no-for-loop': 'off', + 'unicorn/no-array-sort': 'off', '@typescript-eslint/await-thenable': 'error', '@typescript-eslint/no-misused-promises': 'error', 'require-await': 'off', diff --git a/server/src/constants.ts b/server/src/constants.ts index 5af37ef797..c771c1a995 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -36,6 +36,8 @@ export const VECTOR_INDEX_TABLES = { export const VECTORCHORD_LIST_SLACK_FACTOR = 1.2; export const SALT_ROUNDS = 10; +// Syntactically valid bcrypt hash used in login() preventing timing-based user enumeration. +export const LOGIN_DUMMY_HASH = '$2b$10$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZabcde'; export const IWorker = 'IWorker'; diff --git a/server/src/controllers/activity.controller.spec.ts b/server/src/controllers/activity.controller.spec.ts index 7ac6e051f6..0b677b83fa 100644 --- a/server/src/controllers/activity.controller.spec.ts +++ b/server/src/controllers/activity.controller.spec.ts @@ -28,14 +28,16 @@ describe(ActivityController.name, () => { const { status, body } = await request(ctx.getHttpServer()).get('/activities'); expect(status).toEqual(400); expect(body).toEqual( - factory.responses.badRequest(['[albumId] Invalid input: expected string, received undefined']), + factory.responses.validationError([ + { path: ['albumId'], message: 'Invalid input: expected string, received undefined' }, + ]), ); }); it('should reject an invalid albumId', async () => { const { status, body } = await request(ctx.getHttpServer()).get('/activities').query({ albumId: '123' }); expect(status).toEqual(400); - expect(body).toEqual(factory.responses.badRequest(['[albumId] Invalid UUID'])); + expect(body).toEqual(factory.responses.validationError([{ path: ['albumId'], message: 'Invalid UUID' }])); }); it('should reject an invalid assetId', async () => { @@ -43,7 +45,7 @@ describe(ActivityController.name, () => { .get('/activities') .query({ albumId: factory.uuid(), assetId: '123' }); expect(status).toEqual(400); - expect(body).toEqual(factory.responses.badRequest(['[assetId] Invalid UUID'])); + expect(body).toEqual(factory.responses.validationError([{ path: ['assetId'], message: 'Invalid UUID' }])); }); }); @@ -58,7 +60,7 @@ describe(ActivityController.name, () => { .post('/activities') .send({ albumId: '123', type: 'like' }); expect(status).toEqual(400); - expect(body).toEqual(factory.responses.badRequest(['[albumId] Invalid UUID'])); + expect(body).toEqual(factory.responses.validationError([{ path: ['albumId'], message: 'Invalid UUID' }])); }); it('should require a comment when type is comment', async () => { @@ -66,7 +68,11 @@ describe(ActivityController.name, () => { .post('/activities') .send({ albumId: factory.uuid(), type: 'comment', comment: null }); expect(status).toEqual(400); - expect(body).toEqual(factory.responses.badRequest(['[comment] Invalid input: expected string, received null'])); + expect(body).toEqual( + factory.responses.validationError([ + { path: ['comment'], message: 'Invalid input: expected string, received null' }, + ]), + ); }); }); @@ -79,7 +85,7 @@ describe(ActivityController.name, () => { it('should require a valid uuid', async () => { const { status, body } = await request(ctx.getHttpServer()).delete(`/activities/123`); expect(status).toBe(400); - expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID'])); + expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }])); }); }); }); diff --git a/server/src/controllers/album.controller.spec.ts b/server/src/controllers/album.controller.spec.ts index fadc5103eb..0c7a4eb09f 100644 --- a/server/src/controllers/album.controller.spec.ts +++ b/server/src/controllers/album.controller.spec.ts @@ -27,13 +27,17 @@ describe(AlbumController.name, () => { it('should reject an invalid shared param', async () => { const { status, body } = await request(ctx.getHttpServer()).get('/albums?shared=invalid'); expect(status).toEqual(400); - expect(body).toEqual(factory.responses.badRequest(['[shared] Invalid option: expected one of "true"|"false"'])); + expect(body).toEqual( + factory.responses.validationError([ + { path: ['shared'], message: 'Invalid option: expected one of "true"|"false"' }, + ]), + ); }); it('should reject an invalid assetId param', async () => { const { status, body } = await request(ctx.getHttpServer()).get('/albums?assetId=invalid'); expect(status).toEqual(400); - expect(body).toEqual(factory.responses.badRequest(['[assetId] Invalid UUID'])); + expect(body).toEqual(factory.responses.validationError([{ path: ['assetId'], message: 'Invalid UUID' }])); }); }); diff --git a/server/src/controllers/api-key.controller.spec.ts b/server/src/controllers/api-key.controller.spec.ts index 23a1f8b98c..91a7c43a2d 100644 --- a/server/src/controllers/api-key.controller.spec.ts +++ b/server/src/controllers/api-key.controller.spec.ts @@ -49,7 +49,7 @@ describe(ApiKeyController.name, () => { it('should require a valid uuid', async () => { const { status, body } = await request(ctx.getHttpServer()).get(`/api-keys/123`); expect(status).toBe(400); - expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID'])); + expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }])); }); }); @@ -64,7 +64,7 @@ describe(ApiKeyController.name, () => { .put(`/api-keys/123`) .send({ name: 'new name', permissions: [Permission.All] }); expect(status).toBe(400); - expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID'])); + expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }])); }); it('should allow updating just the name', async () => { @@ -84,7 +84,7 @@ describe(ApiKeyController.name, () => { it('should require a valid uuid', async () => { const { status, body } = await request(ctx.getHttpServer()).delete(`/api-keys/123`); expect(status).toBe(400); - expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID'])); + expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }])); }); }); }); diff --git a/server/src/controllers/asset-media.controller.spec.ts b/server/src/controllers/asset-media.controller.spec.ts index 6a328b1f6d..056c3d4df7 100644 --- a/server/src/controllers/asset-media.controller.spec.ts +++ b/server/src/controllers/asset-media.controller.spec.ts @@ -80,7 +80,9 @@ describe(AssetMediaController.name, () => { expect(status).toBe(400); expect(body).toEqual( - factory.responses.badRequest(['[metadata] Invalid input: expected JSON string, received string']), + factory.responses.validationError([ + { path: ['metadata'], message: 'Invalid input: expected JSON string, received string' }, + ]), ); }); @@ -91,8 +93,8 @@ describe(AssetMediaController.name, () => { .field({ ...makeUploadDto({ omit: 'fileCreatedAt' }) }); expect(status).toBe(400); expect(body).toEqual( - factory.responses.badRequest([ - '[fileCreatedAt] Invalid input: expected ISO 8601 datetime string, received undefined', + factory.responses.validationError([ + { path: ['fileCreatedAt'], message: 'Invalid input: expected ISO 8601 datetime string, received undefined' }, ]), ); }); @@ -104,8 +106,8 @@ describe(AssetMediaController.name, () => { .field(makeUploadDto({ omit: 'fileModifiedAt' })); expect(status).toBe(400); expect(body).toEqual( - factory.responses.badRequest([ - '[fileModifiedAt] Invalid input: expected ISO 8601 datetime string, received undefined', + factory.responses.validationError([ + { path: ['fileModifiedAt'], message: 'Invalid input: expected ISO 8601 datetime string, received undefined' }, ]), ); }); @@ -117,7 +119,9 @@ describe(AssetMediaController.name, () => { .field({ ...makeUploadDto(), isFavorite: 'not-a-boolean' }); expect(status).toBe(400); expect(body).toEqual( - factory.responses.badRequest(['[isFavorite] Invalid option: expected one of "true"|"false"']), + factory.responses.validationError([ + { path: ['isFavorite'], message: 'Invalid option: expected one of "true"|"false"' }, + ]), ); }); @@ -128,7 +132,9 @@ describe(AssetMediaController.name, () => { .field({ ...makeUploadDto(), visibility: 'not-an-option' }); expect(status).toBe(400); expect(body).toEqual( - factory.responses.badRequest([expect.stringContaining('[visibility] Invalid option: expected one of')]), + factory.responses.validationError([ + { path: ['visibility'], message: expect.stringContaining('Invalid option: expected one of') }, + ]), ); }); diff --git a/server/src/controllers/asset.controller.spec.ts b/server/src/controllers/asset.controller.spec.ts index 3c01e3d0a9..acdcb84403 100644 --- a/server/src/controllers/asset.controller.spec.ts +++ b/server/src/controllers/asset.controller.spec.ts @@ -31,7 +31,7 @@ describe(AssetController.name, () => { .send({ ids: ['123'] }); expect(status).toBe(400); - expect(body).toEqual(factory.responses.badRequest(['[ids.0] Invalid UUID'])); + expect(body).toEqual(factory.responses.validationError([{ path: ['ids', 0], message: 'Invalid UUID' }])); }); it('should require duplicateId to be a string', async () => { @@ -42,7 +42,9 @@ describe(AssetController.name, () => { expect(status).toBe(400); expect(body).toEqual( - factory.responses.badRequest(['[duplicateId] Invalid input: expected string, received boolean']), + factory.responses.validationError([ + { path: ['duplicateId'], message: 'Invalid input: expected string, received boolean' }, + ]), ); }); @@ -70,7 +72,7 @@ describe(AssetController.name, () => { .send({ ids: ['123'] }); expect(status).toBe(400); - expect(body).toEqual(factory.responses.badRequest(['[ids.0] Invalid UUID'])); + expect(body).toEqual(factory.responses.validationError([{ path: ['ids', 0], message: 'Invalid UUID' }])); }); }); @@ -83,7 +85,7 @@ describe(AssetController.name, () => { it('should require a valid id', async () => { const { status, body } = await request(ctx.getHttpServer()).get(`/assets/123`); expect(status).toBe(400); - expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID'])); + expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }])); }); }); @@ -97,12 +99,10 @@ describe(AssetController.name, () => { const { status, body } = await request(ctx.getHttpServer()).put('/assets/copy').send({}); expect(status).toBe(400); expect(body).toEqual( - factory.responses.badRequest( - expect.arrayContaining([ - '[sourceId] Invalid input: expected string, received undefined', - '[targetId] Invalid input: expected string, received undefined', - ]), - ), + factory.responses.validationError([ + { path: ['sourceId'], message: 'Invalid input: expected string, received undefined' }, + { path: ['targetId'], message: 'Invalid input: expected string, received undefined' }, + ]), ); }); @@ -125,7 +125,9 @@ describe(AssetController.name, () => { .put('/assets/metadata') .send({ items: [{ assetId: '123', key: 'test', value: {} }] }); expect(status).toBe(400); - expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['[items.0.assetId] Invalid UUID']))); + expect(body).toEqual( + factory.responses.validationError([{ path: ['items', 0, 'assetId'], message: 'Invalid UUID' }]), + ); }); it('should require a key', async () => { @@ -134,9 +136,9 @@ describe(AssetController.name, () => { .send({ items: [{ assetId: factory.uuid(), value: {} }] }); expect(status).toBe(400); expect(body).toEqual( - factory.responses.badRequest( - expect.arrayContaining(['[items.0.key] Invalid input: expected string, received undefined']), - ), + factory.responses.validationError([ + { path: ['items', 0, 'key'], message: 'Invalid input: expected string, received undefined' }, + ]), ); }); @@ -159,7 +161,9 @@ describe(AssetController.name, () => { .delete('/assets/metadata') .send({ items: [{ assetId: '123', key: 'test' }] }); expect(status).toBe(400); - expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['[items.0.assetId] Invalid UUID']))); + expect(body).toEqual( + factory.responses.validationError([{ path: ['items', 0, 'assetId'], message: 'Invalid UUID' }]), + ); }); it('should require a key', async () => { @@ -168,9 +172,9 @@ describe(AssetController.name, () => { .send({ items: [{ assetId: factory.uuid() }] }); expect(status).toBe(400); expect(body).toEqual( - factory.responses.badRequest( - expect.arrayContaining(['[items.0.key] Invalid input: expected string, received undefined']), - ), + factory.responses.validationError([ + { path: ['items', 0, 'key'], message: 'Invalid input: expected string, received undefined' }, + ]), ); }); @@ -191,33 +195,56 @@ describe(AssetController.name, () => { it('should require a valid id', async () => { const { status, body } = await request(ctx.getHttpServer()).put(`/assets/123`); expect(status).toBe(400); - expect(body).toEqual(factory.responses.badRequest(['Invalid input: expected object, received undefined'])); + expect(body).toEqual( + factory.responses.validationError([ + { path: [], message: 'Invalid input: expected object, received undefined' }, + ]), + ); }); it('should reject invalid gps coordinates', async () => { - for (const test of [ - { latitude: 12 }, - { longitude: 12 }, - { latitude: 12, longitude: 'abc' }, - { latitude: 'abc', longitude: 12 }, - { latitude: null, longitude: 12 }, - { latitude: 12, longitude: null }, - { latitude: 91, longitude: 12 }, - { latitude: -91, longitude: 12 }, - { latitude: 12, longitude: -181 }, - { latitude: 12, longitude: 181 }, - ]) { + for (const [test, errors] of [ + [{ latitude: 12 }, [{ path: [], message: 'Latitude and longitude must be provided together' }]], + [{ longitude: 12 }, [{ path: [], message: 'Latitude and longitude must be provided together' }]], + [ + { latitude: 12, longitude: 'abc' }, + [{ path: ['longitude'], message: 'Invalid input: expected number, received string' }], + ], + [ + { latitude: 'abc', longitude: 12 }, + [{ path: ['latitude'], message: 'Invalid input: expected number, received string' }], + ], + [ + { latitude: null, longitude: 12 }, + [{ path: ['latitude'], message: 'Invalid input: expected number, received null' }], + ], + [ + { latitude: 12, longitude: null }, + [{ path: ['longitude'], message: 'Invalid input: expected number, received null' }], + ], + [{ latitude: 91, longitude: 12 }, [{ path: ['latitude'], message: 'Too big: expected number to be <=90' }]], + [{ latitude: -91, longitude: 12 }, [{ path: ['latitude'], message: 'Too small: expected number to be >=-90' }]], + [ + { latitude: 12, longitude: -181 }, + [{ path: ['longitude'], message: 'Too small: expected number to be >=-180' }], + ], + [{ latitude: 12, longitude: 181 }, [{ path: ['longitude'], message: 'Too big: expected number to be <=180' }]], + ] as const) { const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}`).send(test); expect(status).toBe(400); - expect(body).toEqual(factory.responses.badRequest()); + expect(body).toEqual(factory.responses.validationError(errors)); } }); it('should reject invalid rating', async () => { - for (const test of [{ rating: 7 }, { rating: 3.5 }, { rating: -2 }]) { + for (const [test, errors] of [ + [{ rating: 7 }, [{ path: ['rating'], message: 'Too big: expected number to be <=5' }]], + [{ rating: 3.5 }, [{ path: ['rating'], message: 'Invalid input: expected int, received number' }]], + [{ rating: -2 }, [{ path: ['rating'], message: 'Too small: expected number to be >=-1' }]], + ] as const) { const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}`).send(test); expect(status).toBe(400); - expect(body).toEqual(factory.responses.badRequest()); + expect(body).toEqual(factory.responses.validationError(errors)); } }); @@ -261,13 +288,17 @@ describe(AssetController.name, () => { it('should require a valid id', async () => { const { status, body } = await request(ctx.getHttpServer()).put(`/assets/123/metadata`).send({ items: [] }); expect(status).toBe(400); - expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['[id] Invalid UUID']))); + expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }])); }); it('should require items to be an array', async () => { const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}/metadata`).send({}); expect(status).toBe(400); - expect(body).toEqual(factory.responses.badRequest(['[items] Invalid input: expected array, received undefined'])); + expect(body).toEqual( + factory.responses.validationError([ + { path: ['items'], message: 'Invalid input: expected array, received undefined' }, + ]), + ); }); it('should require each item to have a valid key', async () => { @@ -276,7 +307,9 @@ describe(AssetController.name, () => { .send({ items: [{ value: { some: 'value' } }] }); expect(status).toBe(400); expect(body).toEqual( - factory.responses.badRequest(['[items.0.key] Invalid input: expected string, received undefined']), + factory.responses.validationError([ + { path: ['items', 0, 'key'], message: 'Invalid input: expected string, received undefined' }, + ]), ); }); @@ -286,9 +319,9 @@ describe(AssetController.name, () => { .send({ items: [{ key: 'mobile-app', value: null }] }); expect(status).toBe(400); expect(body).toEqual( - factory.responses.badRequest( - expect.arrayContaining(['[items.0.value] Invalid input: expected record, received null']), - ), + factory.responses.validationError([ + { path: ['items', 0, 'value'], message: 'Invalid input: expected record, received null' }, + ]), ); }); @@ -326,7 +359,7 @@ describe(AssetController.name, () => { it('should require a valid id', async () => { const { status, body } = await request(ctx.getHttpServer()).get(`/assets/123/metadata/mobile-app`); expect(status).toBe(400); - expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['[id] Invalid UUID']))); + expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }])); }); }); @@ -376,7 +409,7 @@ describe(AssetController.name, () => { }); expect(status).toBe(400); - expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['[id] Invalid UUID']))); + expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }])); }); it('should check the action and parameters discriminator', async () => { @@ -398,13 +431,12 @@ describe(AssetController.name, () => { expect(status).toBe(400); expect(body).toEqual( - factory.responses.badRequest( - expect.arrayContaining([ - expect.stringContaining( - "[edits.0.parameters] Invalid parameters for action 'rotate', expecting keys: angle", - ), - ]), - ), + factory.responses.validationError([ + { + path: ['edits', 0, 'parameters'], + message: expect.stringContaining("Invalid parameters for action 'rotate', expecting keys: angle"), + }, + ]), ); }); @@ -413,7 +445,11 @@ describe(AssetController.name, () => { .put(`/assets/${factory.uuid()}/edits`) .send({ edits: [] }); expect(status).toBe(400); - expect(body).toEqual(factory.responses.badRequest(['[edits] Too small: expected array to have >=1 items'])); + expect(body).toEqual( + factory.responses.validationError([ + { path: ['edits'], message: 'Too small: expected array to have >=1 items' }, + ]), + ); }); }); @@ -426,7 +462,7 @@ describe(AssetController.name, () => { it('should require a valid id', async () => { const { status, body } = await request(ctx.getHttpServer()).delete(`/assets/123/metadata/mobile-app`); expect(status).toBe(400); - expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID'])); + expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }])); }); }); }); diff --git a/server/src/controllers/auth.controller.spec.ts b/server/src/controllers/auth.controller.spec.ts index a61397e75c..d105dd90b9 100644 --- a/server/src/controllers/auth.controller.spec.ts +++ b/server/src/controllers/auth.controller.spec.ts @@ -28,19 +28,27 @@ describe(AuthController.name, () => { it('should require an email address', async () => { const { status, body } = await request(ctx.getHttpServer()).post('/auth/admin-sign-up').send({ name, password }); expect(status).toEqual(400); - expect(body).toEqual(errorDto.badRequest()); + expect(body).toEqual( + errorDto.validationError([{ path: ['email'], message: 'Invalid input: expected email, received undefined' }]), + ); }); it('should require a password', async () => { const { status, body } = await request(ctx.getHttpServer()).post('/auth/admin-sign-up').send({ name, email }); expect(status).toEqual(400); - expect(body).toEqual(errorDto.badRequest()); + expect(body).toEqual( + errorDto.validationError([ + { path: ['password'], message: 'Invalid input: expected string, received undefined' }, + ]), + ); }); it('should require a name', async () => { const { status, body } = await request(ctx.getHttpServer()).post('/auth/admin-sign-up').send({ email, password }); expect(status).toEqual(400); - expect(body).toEqual(errorDto.badRequest()); + expect(body).toEqual( + errorDto.validationError([{ path: ['name'], message: 'Invalid input: expected string, received undefined' }]), + ); }); it('should require a valid email', async () => { @@ -48,7 +56,9 @@ describe(AuthController.name, () => { .post('/auth/admin-sign-up') .send({ name, email: 'immich', password }); expect(status).toEqual(400); - expect(body).toEqual(errorDto.badRequest()); + expect(body).toEqual( + errorDto.validationError([{ path: ['email'], message: 'Invalid input: expected email, received string' }]), + ); }); it('should transform email to lower case', async () => { @@ -73,9 +83,9 @@ describe(AuthController.name, () => { const { status, body } = await request(ctx.getHttpServer()).post('/auth/login').send({ name: 'admin' }); expect(status).toBe(400); expect(body).toEqual( - errorDto.badRequest([ - '[email] Invalid input: expected email, received undefined', - '[password] Invalid input: expected string, received undefined', + errorDto.validationError([ + { path: ['email'], message: 'Invalid input: expected email, received undefined' }, + { path: ['password'], message: 'Invalid input: expected string, received undefined' }, ]), ); }); @@ -85,7 +95,9 @@ describe(AuthController.name, () => { .post('/auth/login') .send({ name: 'admin', email: null, password: 'password' }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[email] Invalid input: expected email, received object'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['email'], message: 'Invalid input: expected email, received object' }]), + ); }); it(`should not allow null password`, async () => { @@ -93,7 +105,9 @@ describe(AuthController.name, () => { .post('/auth/login') .send({ name: 'admin', email: 'admin@immich.cloud', password: null }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[password] Invalid input: expected string, received null'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['password'], message: 'Invalid input: expected string, received null' }]), + ); }); it('should reject an invalid email', async () => { @@ -104,7 +118,9 @@ describe(AuthController.name, () => { .send({ name: 'admin', email: [], password: 'password' }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[email] Invalid input: expected email, received object'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['email'], message: 'Invalid input: expected email, received object' }]), + ); }); it('should transform the email to all lowercase', async () => { @@ -195,19 +211,31 @@ describe(AuthController.name, () => { it('should reject 5 digits', async () => { const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: '12345' }); expect(status).toEqual(400); - expect(body).toEqual(errorDto.badRequest([String.raw`[pinCode] Invalid string: must match pattern /^\d{6}$/`])); + expect(body).toEqual( + errorDto.validationError([ + { path: ['pinCode'], message: String.raw`Invalid string: must match pattern /^\d{6}$/` }, + ]), + ); }); it('should reject 7 digits', async () => { const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: '1234567' }); expect(status).toEqual(400); - expect(body).toEqual(errorDto.badRequest([String.raw`[pinCode] Invalid string: must match pattern /^\d{6}$/`])); + expect(body).toEqual( + errorDto.validationError([ + { path: ['pinCode'], message: String.raw`Invalid string: must match pattern /^\d{6}$/` }, + ]), + ); }); it('should reject non-numbers', async () => { const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: 'A12345' }); expect(status).toEqual(400); - expect(body).toEqual(errorDto.badRequest([String.raw`[pinCode] Invalid string: must match pattern /^\d{6}$/`])); + expect(body).toEqual( + errorDto.validationError([ + { path: ['pinCode'], message: String.raw`Invalid string: must match pattern /^\d{6}$/` }, + ]), + ); }); }); diff --git a/server/src/controllers/duplicate.controller.spec.ts b/server/src/controllers/duplicate.controller.spec.ts index 3e11b628e3..7bbafb4665 100644 --- a/server/src/controllers/duplicate.controller.spec.ts +++ b/server/src/controllers/duplicate.controller.spec.ts @@ -41,7 +41,7 @@ describe(DuplicateController.name, () => { it('should require a valid uuid', async () => { const { status, body } = await request(ctx.getHttpServer()).delete(`/duplicates/123`); expect(status).toBe(400); - expect(body).toEqual(factory.responses.badRequest(['[id] Invalid UUID'])); + expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }])); }); }); }); diff --git a/server/src/controllers/maintenance.controller.spec.ts b/server/src/controllers/maintenance.controller.spec.ts index 07c0149463..630bb7c8b8 100644 --- a/server/src/controllers/maintenance.controller.spec.ts +++ b/server/src/controllers/maintenance.controller.spec.ts @@ -31,7 +31,9 @@ describe(MaintenanceController.name, () => { }); expect(status).toBe(400); expect(body).toEqual( - errorDto.badRequest(['[restoreBackupFilename] Backup filename is required when action is restore_database']), + errorDto.validationError([ + { path: ['restoreBackupFilename'], message: 'Backup filename is required when action is restore_database' }, + ]), ); expect(ctx.authenticate).toHaveBeenCalled(); }); diff --git a/server/src/controllers/memory.controller.spec.ts b/server/src/controllers/memory.controller.spec.ts index 6a84edce45..64d225f155 100644 --- a/server/src/controllers/memory.controller.spec.ts +++ b/server/src/controllers/memory.controller.spec.ts @@ -47,7 +47,11 @@ describe(MemoryController.name, () => { }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[data.year] Invalid input: expected number, received undefined'])); + expect(body).toEqual( + errorDto.validationError([ + { path: ['data', 'year'], message: 'Invalid input: expected number, received undefined' }, + ]), + ); }); it('should accept showAt and hideAt', async () => { @@ -81,7 +85,7 @@ describe(MemoryController.name, () => { it('should require a valid id', async () => { const { status, body } = await request(ctx.getHttpServer()).get(`/memories/invalid`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID'])); + expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }])); }); }); @@ -94,13 +98,15 @@ describe(MemoryController.name, () => { it('should require a valid id', async () => { const { status, body } = await request(ctx.getHttpServer()).put(`/memories/invalid`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['Invalid input: expected object, received undefined'])); + expect(body).toEqual( + errorDto.validationError([{ path: [], message: 'Invalid input: expected object, received undefined' }]), + ); }); it('should require at least one field', async () => { const { status, body } = await request(ctx.getHttpServer()).put(`/memories/${factory.uuid()}`).send({}); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['At least one field must be provided'])); + expect(body).toEqual(errorDto.validationError([{ path: [], message: 'At least one field must be provided' }])); }); }); @@ -120,7 +126,7 @@ describe(MemoryController.name, () => { it('should require a valid id', async () => { const { status, body } = await request(ctx.getHttpServer()).put(`/memories/invalid/assets`).send({ ids: [] }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID'])); + expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }])); }); it('should require a valid asset id', async () => { @@ -128,7 +134,7 @@ describe(MemoryController.name, () => { .put(`/memories/${factory.uuid()}/assets`) .send({ ids: ['invalid'] }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[ids.0] Invalid UUID'])); + expect(body).toEqual(errorDto.validationError([{ path: ['ids', 0], message: 'Invalid UUID' }])); }); }); @@ -141,7 +147,7 @@ describe(MemoryController.name, () => { it('should require a valid id', async () => { const { status, body } = await request(ctx.getHttpServer()).delete(`/memories/invalid/assets`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID'])); + expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }])); }); it('should require a valid asset id', async () => { @@ -149,7 +155,7 @@ describe(MemoryController.name, () => { .delete(`/memories/${factory.uuid()}/assets`) .send({ ids: ['invalid'] }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[ids.0] Invalid UUID'])); + expect(body).toEqual(errorDto.validationError([{ path: ['ids', 0], message: 'Invalid UUID' }])); }); }); }); diff --git a/server/src/controllers/notification.controller.spec.ts b/server/src/controllers/notification.controller.spec.ts index e9886ebb07..1759e13404 100644 --- a/server/src/controllers/notification.controller.spec.ts +++ b/server/src/controllers/notification.controller.spec.ts @@ -31,7 +31,11 @@ describe(NotificationController.name, () => { .query({ level: 'invalid' }) .set('Authorization', `Bearer token`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest([expect.stringContaining('[level] Invalid option: expected one of')])); + expect(body).toEqual( + errorDto.validationError([ + { path: ['level'], message: expect.stringContaining('Invalid option: expected one of') }, + ]), + ); }); }); @@ -45,7 +49,9 @@ describe(NotificationController.name, () => { it('should require a list', async () => { const { status, body } = await request(ctx.getHttpServer()).put(`/notifications`).send({ ids: true }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[ids] Invalid input: expected array, received boolean'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['ids'], message: 'Invalid input: expected array, received boolean' }]), + ); }); it('should require uuids', async () => { @@ -53,7 +59,9 @@ describe(NotificationController.name, () => { .put(`/notifications`) .send({ ids: [true] }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[ids.0] Invalid input: expected string, received boolean'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['ids', 0], message: 'Invalid input: expected string, received boolean' }]), + ); }); it('should accept valid uuids', async () => { @@ -75,7 +83,7 @@ describe(NotificationController.name, () => { it('should require a valid uuid', async () => { const { status, body } = await request(ctx.getHttpServer()).get(`/notifications/123`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID'])); + expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }])); }); }); diff --git a/server/src/controllers/partner.controller.spec.ts b/server/src/controllers/partner.controller.spec.ts index 0661e9121b..d6541411b8 100644 --- a/server/src/controllers/partner.controller.spec.ts +++ b/server/src/controllers/partner.controller.spec.ts @@ -33,7 +33,9 @@ describe(PartnerController.name, () => { const { status, body } = await request(ctx.getHttpServer()).get(`/partners`).set('Authorization', `Bearer token`); expect(status).toBe(400); expect(body).toEqual( - errorDto.badRequest([expect.stringContaining('[direction] Invalid option: expected one of')]), + errorDto.validationError([ + { path: ['direction'], message: expect.stringContaining('Invalid option: expected one of') }, + ]), ); }); @@ -44,7 +46,9 @@ describe(PartnerController.name, () => { .set('Authorization', `Bearer token`); expect(status).toBe(400); expect(body).toEqual( - errorDto.badRequest([expect.stringContaining('[direction] Invalid option: expected one of')]), + errorDto.validationError([ + { path: ['direction'], message: expect.stringContaining('Invalid option: expected one of') }, + ]), ); }); }); @@ -61,7 +65,7 @@ describe(PartnerController.name, () => { .send({ sharedWithId: 'invalid' }) .set('Authorization', `Bearer token`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[sharedWithId] Invalid UUID'])); + expect(body).toEqual(errorDto.validationError([{ path: ['sharedWithId'], message: 'Invalid UUID' }])); }); }); @@ -77,7 +81,7 @@ describe(PartnerController.name, () => { .send({ inTimeline: true }) .set('Authorization', `Bearer token`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID'])); + expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }])); }); }); @@ -92,7 +96,7 @@ describe(PartnerController.name, () => { .delete(`/partners/invalid`) .set('Authorization', `Bearer token`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID'])); + expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }])); }); }); }); diff --git a/server/src/controllers/person.controller.spec.ts b/server/src/controllers/person.controller.spec.ts index c6c0a1c91f..cf3a5e56b0 100644 --- a/server/src/controllers/person.controller.spec.ts +++ b/server/src/controllers/person.controller.spec.ts @@ -35,7 +35,7 @@ describe(PersonController.name, () => { .query({ closestPersonId: 'invalid' }) .set('Authorization', `Bearer token`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[closestPersonId] Invalid UUID'])); + expect(body).toEqual(errorDto.validationError([{ path: ['closestPersonId'], message: 'Invalid UUID' }])); }); it(`should require closestAssetId to be a uuid`, async () => { @@ -44,7 +44,7 @@ describe(PersonController.name, () => { .query({ closestAssetId: 'invalid' }) .set('Authorization', `Bearer token`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[closestAssetId] Invalid UUID'])); + expect(body).toEqual(errorDto.validationError([{ path: ['closestAssetId'], message: 'Invalid UUID' }])); }); }); @@ -76,7 +76,7 @@ describe(PersonController.name, () => { .delete('/people') .send({ ids: ['invalid'] }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[ids.0] Invalid UUID'])); + expect(body).toEqual(errorDto.validationError([{ path: ['ids', 0], message: 'Invalid UUID' }])); }); it('should respond with 204', async () => { @@ -104,7 +104,9 @@ describe(PersonController.name, () => { it('should require a valid uuid', async () => { const { status, body } = await request(ctx.getHttpServer()).put(`/people/123`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['Invalid input: expected object, received undefined'])); + expect(body).toEqual( + errorDto.validationError([{ path: [], message: 'Invalid input: expected object, received undefined' }]), + ); }); it(`should not allow a null name`, async () => { @@ -113,7 +115,9 @@ describe(PersonController.name, () => { .send({ name: null }) .set('Authorization', `Bearer token`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[name] Invalid input: expected string, received null'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['name'], message: 'Invalid input: expected string, received null' }]), + ); }); it(`should require featureFaceAssetId to be a uuid`, async () => { @@ -122,7 +126,7 @@ describe(PersonController.name, () => { .send({ featureFaceAssetId: 'invalid' }) .set('Authorization', `Bearer token`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[featureFaceAssetId] Invalid UUID'])); + expect(body).toEqual(errorDto.validationError([{ path: ['featureFaceAssetId'], message: 'Invalid UUID' }])); }); it(`should require isFavorite to be a boolean`, async () => { @@ -131,7 +135,11 @@ describe(PersonController.name, () => { .send({ isFavorite: 'invalid' }) .set('Authorization', `Bearer token`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[isFavorite] Invalid input: expected boolean, received string'])); + expect(body).toEqual( + errorDto.validationError([ + { path: ['isFavorite'], message: 'Invalid input: expected boolean, received string' }, + ]), + ); }); it(`should require isHidden to be a boolean`, async () => { @@ -140,7 +148,9 @@ describe(PersonController.name, () => { .send({ isHidden: 'invalid' }) .set('Authorization', `Bearer token`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[isHidden] Invalid input: expected boolean, received string'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['isHidden'], message: 'Invalid input: expected boolean, received string' }]), + ); }); it('should map an empty birthDate to null', async () => { @@ -154,7 +164,11 @@ describe(PersonController.name, () => { .put(`/people/${factory.uuid()}`) .send({ birthDate: false }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[birthDate] Invalid input: expected string, received boolean'])); + expect(body).toEqual( + errorDto.validationError([ + { path: ['birthDate'], message: 'Invalid input: expected string, received boolean' }, + ]), + ); }); it('should not accept an invalid birth date (number)', async () => { @@ -162,7 +176,9 @@ describe(PersonController.name, () => { .put(`/people/${factory.uuid()}`) .send({ birthDate: 123_456 }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[birthDate] Invalid input: expected string, received number'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['birthDate'], message: 'Invalid input: expected string, received number' }]), + ); }); it('should not accept a birth date in the future)', async () => { @@ -170,7 +186,9 @@ describe(PersonController.name, () => { .put(`/people/${factory.uuid()}`) .send({ birthDate: '9999-01-01' }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[birthDate] Birth date cannot be in the future'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['birthDate'], message: 'Birth date cannot be in the future' }]), + ); }); }); @@ -183,7 +201,7 @@ describe(PersonController.name, () => { it('should require a valid uuid', async () => { const { status, body } = await request(ctx.getHttpServer()).delete(`/people/invalid`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID'])); + expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }])); }); it('should respond with 204', async () => { diff --git a/server/src/controllers/search.controller.spec.ts b/server/src/controllers/search.controller.spec.ts index 81cdb5ef6a..a1fed4c7ae 100644 --- a/server/src/controllers/search.controller.spec.ts +++ b/server/src/controllers/search.controller.spec.ts @@ -27,31 +27,41 @@ describe(SearchController.name, () => { it('should reject page as a string', async () => { const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ page: 'abc' }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[page] Invalid input: expected number, received string'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['page'], message: 'Invalid input: expected number, received string' }]), + ); }); it('should reject page as a negative number', async () => { const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ page: -10 }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[page] Too small: expected number to be >=1'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['page'], message: 'Too small: expected number to be >=1' }]), + ); }); it('should reject page as 0', async () => { const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ page: 0 }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[page] Too small: expected number to be >=1'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['page'], message: 'Too small: expected number to be >=1' }]), + ); }); it('should reject size as a string', async () => { const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ size: 'abc' }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[size] Invalid input: expected number, received string'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['size'], message: 'Invalid input: expected number, received string' }]), + ); }); it('should reject an invalid size', async () => { const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ size: -1 }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[size] Too small: expected number to be >=1'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['size'], message: 'Too small: expected number to be >=1' }]), + ); }); it('should reject an visibility as not an enum', async () => { @@ -60,7 +70,9 @@ describe(SearchController.name, () => { .send({ visibility: 'immich' }); expect(status).toBe(400); expect(body).toEqual( - errorDto.badRequest([expect.stringContaining('[visibility] Invalid option: expected one of')]), + errorDto.validationError([ + { path: ['visibility'], message: expect.stringContaining('Invalid option: expected one of') }, + ]), ); }); @@ -69,7 +81,11 @@ describe(SearchController.name, () => { .post('/search/metadata') .send({ isFavorite: 'immich' }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[isFavorite] Invalid input: expected boolean, received string'])); + expect(body).toEqual( + errorDto.validationError([ + { path: ['isFavorite'], message: 'Invalid input: expected boolean, received string' }, + ]), + ); }); it('should reject an isEncoded as not a boolean', async () => { @@ -77,7 +93,11 @@ describe(SearchController.name, () => { .post('/search/metadata') .send({ isEncoded: 'immich' }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[isEncoded] Invalid input: expected boolean, received string'])); + expect(body).toEqual( + errorDto.validationError([ + { path: ['isEncoded'], message: 'Invalid input: expected boolean, received string' }, + ]), + ); }); it('should reject an isOffline as not a boolean', async () => { @@ -85,13 +105,19 @@ describe(SearchController.name, () => { .post('/search/metadata') .send({ isOffline: 'immich' }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[isOffline] Invalid input: expected boolean, received string'])); + expect(body).toEqual( + errorDto.validationError([ + { path: ['isOffline'], message: 'Invalid input: expected boolean, received string' }, + ]), + ); }); it('should reject an isMotion as not a boolean', async () => { const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ isMotion: 'immich' }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[isMotion] Invalid input: expected boolean, received string'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['isMotion'], message: 'Invalid input: expected boolean, received string' }]), + ); }); describe('POST /search/random', () => { @@ -105,7 +131,11 @@ describe(SearchController.name, () => { .post('/search/random') .send({ withStacked: 'immich' }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[withStacked] Invalid input: expected boolean, received string'])); + expect(body).toEqual( + errorDto.validationError([ + { path: ['withStacked'], message: 'Invalid input: expected boolean, received string' }, + ]), + ); }); it('should reject if withPeople is not a boolean', async () => { @@ -113,7 +143,11 @@ describe(SearchController.name, () => { .post('/search/random') .send({ withPeople: 'immich' }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[withPeople] Invalid input: expected boolean, received string'])); + expect(body).toEqual( + errorDto.validationError([ + { path: ['withPeople'], message: 'Invalid input: expected boolean, received string' }, + ]), + ); }); }); @@ -140,7 +174,9 @@ describe(SearchController.name, () => { it('should require a name', async () => { const { status, body } = await request(ctx.getHttpServer()).get('/search/person').send({}); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[name] Invalid input: expected string, received undefined'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['name'], message: 'Invalid input: expected string, received undefined' }]), + ); }); }); @@ -153,7 +189,9 @@ describe(SearchController.name, () => { it('should require a name', async () => { const { status, body } = await request(ctx.getHttpServer()).get('/search/places').send({}); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[name] Invalid input: expected string, received undefined'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['name'], message: 'Invalid input: expected string, received undefined' }]), + ); }); }); @@ -173,7 +211,11 @@ describe(SearchController.name, () => { it('should require a type', async () => { const { status, body } = await request(ctx.getHttpServer()).get('/search/suggestions').send({}); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest([expect.stringContaining('[type] Invalid option: expected one of')])); + expect(body).toEqual( + errorDto.validationError([ + { path: ['type'], message: expect.stringContaining('Invalid option: expected one of') }, + ]), + ); }); }); }); diff --git a/server/src/controllers/sync.controller.spec.ts b/server/src/controllers/sync.controller.spec.ts index 07b0d7199f..cae7650d9a 100644 --- a/server/src/controllers/sync.controller.spec.ts +++ b/server/src/controllers/sync.controller.spec.ts @@ -35,7 +35,11 @@ describe(SyncController.name, () => { .post('/sync/stream') .send({ types: ['invalid'] }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest([expect.stringContaining('[types.0] Invalid option: expected one of')])); + expect(body).toEqual( + errorDto.validationError([ + { path: ['types', 0], message: expect.stringContaining('Invalid option: expected one of') }, + ]), + ); expect(ctx.authenticate).toHaveBeenCalled(); }); }); @@ -57,7 +61,9 @@ describe(SyncController.name, () => { const acks = Array.from({ length: 1001 }, (_, i) => `ack-${i}`); const { status, body } = await request(ctx.getHttpServer()).post('/sync/ack').send({ acks }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[acks] Too big: expected array to have <=1000 items'])); + expect(body).toEqual( + errorDto.validationError([{ path: ['acks'], message: 'Too big: expected array to have <=1000 items' }]), + ); expect(ctx.authenticate).toHaveBeenCalled(); }); }); @@ -73,7 +79,11 @@ describe(SyncController.name, () => { .delete('/sync/ack') .send({ types: ['invalid'] }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest([expect.stringContaining('[types.0] Invalid option: expected one of')])); + expect(body).toEqual( + errorDto.validationError([ + { path: ['types', 0], message: expect.stringContaining('Invalid option: expected one of') }, + ]), + ); expect(ctx.authenticate).toHaveBeenCalled(); }); }); diff --git a/server/src/controllers/system-config.controller.spec.ts b/server/src/controllers/system-config.controller.spec.ts index a07dee64ad..4e86aa56db 100644 --- a/server/src/controllers/system-config.controller.spec.ts +++ b/server/src/controllers/system-config.controller.spec.ts @@ -67,8 +67,11 @@ describe(SystemConfigController.name, () => { const { status, body } = await request(ctx.getHttpServer()).put('/system-config').send(config); expect(status).toBe(400); expect(body).toEqual( - errorDto.badRequest([ - '[nightlyTasks.startTime] Invalid input: expected string in HH:mm format, received string', + errorDto.validationError([ + { + path: ['nightlyTasks', 'startTime'], + message: 'Invalid input: expected string in HH:mm format, received string', + }, ]), ); }); @@ -86,7 +89,9 @@ describe(SystemConfigController.name, () => { const { status, body } = await request(ctx.getHttpServer()).put('/system-config').send(config); expect(status).toBe(400); expect(body).toEqual( - errorDto.badRequest(['[nightlyTasks.databaseCleanup] Invalid input: expected boolean, received string']), + errorDto.validationError([ + { path: ['nightlyTasks', 'databaseCleanup'], message: 'Invalid input: expected boolean, received string' }, + ]), ); }); }); @@ -116,7 +121,12 @@ describe(SystemConfigController.name, () => { const { status, body } = await request(ctx.getHttpServer()).put('/system-config').send(config); expect(status).toBe(400); expect(body).toEqual( - errorDto.badRequest(['[image.thumbnail.progressive] Invalid input: expected boolean, received string']), + errorDto.validationError([ + { + path: ['image', 'thumbnail', 'progressive'], + message: 'Invalid input: expected boolean, received string', + }, + ]), ); }); }); diff --git a/server/src/controllers/tag.controller.spec.ts b/server/src/controllers/tag.controller.spec.ts index edd0f27980..907e99bb43 100644 --- a/server/src/controllers/tag.controller.spec.ts +++ b/server/src/controllers/tag.controller.spec.ts @@ -54,7 +54,7 @@ describe(TagController.name, () => { it('should require a valid uuid', async () => { const { status, body } = await request(ctx.getHttpServer()).get(`/tags/123`); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[id] Invalid UUID'])); + expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }])); }); }); diff --git a/server/src/controllers/timeline.controller.spec.ts b/server/src/controllers/timeline.controller.spec.ts index f4c18235e4..b07eb5a78c 100644 --- a/server/src/controllers/timeline.controller.spec.ts +++ b/server/src/controllers/timeline.controller.spec.ts @@ -42,7 +42,9 @@ describe(TimelineController.name, () => { const { status, body } = await request(ctx.getHttpServer()).get('/timeline/buckets').query({ bbox: '1,2,3' }); expect(status).toBe(400); expect(body).toEqual( - errorDto.badRequest(['[bbox] bbox must have 4 comma-separated numbers: west,south,east,north'] as any), + errorDto.validationError([ + { path: ['bbox'], message: 'bbox must have 4 comma-separated numbers: west,south,east,north' }, + ]), ); }); @@ -51,7 +53,7 @@ describe(TimelineController.name, () => { .get('/timeline/buckets') .query({ bbox: '1,2,3,invalid' }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['[bbox] bbox parts must be valid numbers'] as any)); + expect(body).toEqual(errorDto.validationError([{ path: ['bbox'], message: 'bbox parts must be valid numbers' }])); }); }); diff --git a/server/src/controllers/user-admin.controller.spec.ts b/server/src/controllers/user-admin.controller.spec.ts index 048f94df5a..b5840a33e1 100644 --- a/server/src/controllers/user-admin.controller.spec.ts +++ b/server/src/controllers/user-admin.controller.spec.ts @@ -78,9 +78,9 @@ describe(UserAdminController.name, () => { .send(dto); expect(status).toBe(400); expect(body).toEqual( - errorDto.badRequest( - expect.arrayContaining(['[quotaSizeInBytes] Invalid input: expected int, received number']), - ), + errorDto.validationError([ + { path: ['quotaSizeInBytes'], message: 'Invalid input: expected int, received number' }, + ]), ); }); @@ -98,9 +98,9 @@ describe(UserAdminController.name, () => { .send(dto); expect(status).toBe(400); expect(body).toEqual( - errorDto.badRequest( - expect.arrayContaining(['[quotaSizeInBytes] Invalid input: expected int, received number']), - ), + errorDto.validationError([ + { path: ['quotaSizeInBytes'], message: 'Invalid input: expected int, received number' }, + ]), ); }); }); @@ -125,9 +125,9 @@ describe(UserAdminController.name, () => { .send({ quotaSizeInBytes: 1.2 }); expect(status).toBe(400); expect(body).toEqual( - errorDto.badRequest( - expect.arrayContaining(['[quotaSizeInBytes] Invalid input: expected int, received number']), - ), + errorDto.validationError([ + { path: ['quotaSizeInBytes'], message: 'Invalid input: expected int, received number' }, + ]), ); }); diff --git a/server/src/controllers/user.controller.spec.ts b/server/src/controllers/user.controller.spec.ts index 3c3e103814..f512e2de39 100644 --- a/server/src/controllers/user.controller.spec.ts +++ b/server/src/controllers/user.controller.spec.ts @@ -43,15 +43,17 @@ describe(UserController.name, () => { expect(ctx.authenticate).toHaveBeenCalled(); }); - for (const key of ['email', 'name']) { + for (const [key, message] of [ + ['email', 'Invalid input: expected email, received object'], + ['name', 'Invalid input: expected string, received null'], + ] as const) { it(`should not allow null ${key}`, async () => { - const dto = { [key]: null }; const { status, body } = await request(ctx.getHttpServer()) .put(`/users/me`) .set('Authorization', `Bearer token`) - .send(dto); + .send({ [key]: null }); expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest()); + expect(body).toEqual(errorDto.validationError([{ path: [key], message }])); }); } diff --git a/server/src/enum.ts b/server/src/enum.ts index f84cffac6c..26db386348 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -603,6 +603,133 @@ export enum ExifOrientation { Rotate270CW = 8, } +/** ITU-T H.273 colour primaries codes. */ +export enum ColorPrimaries { + Reserved = 0, + Bt709 = 1, + Unknown = 2, + Bt470M = 4, + Bt470Bg = 5, + Smpte170M = 6, + Smpte240M = 7, + Film = 8, + Bt2020 = 9, + Smpte428 = 10, + Smpte431 = 11, + Smpte432 = 12, + Ebu3213 = 22, +} + +/** ITU-T H.273 transfer characteristics codes. */ +export enum ColorTransfer { + Reserved = 0, + Bt709 = 1, + Unknown = 2, + Bt470M = 4, + Bt470Bg = 5, + Smpte170M = 6, + Smpte240M = 7, + Linear = 8, + Log100 = 9, + Log316 = 10, + Iec6196624 = 11, + Bt1361E = 12, + Iec6196621 = 13, + Bt202010 = 14, + Bt202012 = 15, + Smpte2084 = 16, + Smpte428 = 17, + AribStdB67 = 18, +} + +/** ITU-T H.273 matrix coefficients codes. */ +export enum ColorMatrix { + Gbr = 0, + Bt709 = 1, + Unknown = 2, + Reserved = 3, + Fcc = 4, + Bt470Bg = 5, + Smpte170M = 6, + Smpte240M = 7, + Ycgco = 8, + Bt2020Nc = 9, + Bt2020C = 10, + Smpte2085 = 11, + ChromaDerivedNc = 12, + ChromaDerivedC = 13, + Ictcp = 14, +} + +/** H.264 `profile_idc` values. */ +// H.264 has a few profiles that have the same value but different names, included so lookup by name works +export enum H264Profile { + ConstrainedBaseline = 66, + // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values + Baseline = 66, + Main = 77, + Extended = 88, + ConstrainedHigh = 100, + // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values + ProgressiveHigh = 100, + // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values + High = 100, + High10 = 110, + High422 = 122, + High444Predictive = 244, +} + +/** HEVC `profile_idc` values. */ +export enum HevcProfile { + Main = 1, + Main10 = 2, + MainStillPicture = 3, + Rext = 4, +} + +/** AV1 `seq_profile` values. */ +export enum Av1Profile { + Main = 0, + High = 1, + Professional = 2, +} + +/** MPEG-4 Audio Object Type values for AAC. */ +export enum AacProfile { + Main = 1, + Lc = 2, + Ssr = 3, + Ltp = 4, + HeAac = 5, + Ld = 23, + HeAacv2 = 29, + Eld = 39, + XheAac = 42, +} + +/** Dolby Vision bitstream profile numbers from the DOVI configuration record. */ +export enum DvProfile { + Dvhe03 = 3, + Dvhe04 = 4, + Dvhe05 = 5, + Dvhe07 = 7, + Dvhe08 = 8, + Dvav09 = 9, + Dav110 = 10, +} + +/** + * Dolby Vision base-layer signal-compatibility ID from the DOVI configuration record. + * Identifies what the base HEVC/AVC layer renders as on a non-DV decoder. + */ +export enum DvSignalCompatibility { + None = 0, + Hdr10 = 1, + Sdr709 = 2, + Hlg = 4, + Sdr2020 = 6, +} + export enum DatabaseExtension { Cube = 'cube', EarthDistance = 'earthdistance', diff --git a/server/src/middleware/global-exception.filter.ts b/server/src/middleware/global-exception.filter.ts index f331df9147..7572274d15 100644 --- a/server/src/middleware/global-exception.filter.ts +++ b/server/src/middleware/global-exception.filter.ts @@ -40,16 +40,16 @@ export class GlobalExceptionFilter implements ExceptionFilter { if (error instanceof ZodValidationException || error instanceof ZodSerializationException) { const zodError = error.getZodError(); if (zodError instanceof ZodError && zodError.issues.length > 0) { - body['message'] = zodError.issues.map((issue) => - issue.path.length > 0 ? `[${issue.path.join('.')}] ${issue.message}` : issue.message, - ); + return { + status, + body: { message: 'Validation failed', errors: zodError.issues }, + }; } } - // remove fields that duplicate the HTTP response line or will be reformatted in a later step + // remove fields injected by NestJS that duplicate the HTTP response line delete body['error']; delete body['statusCode']; - delete body['errors']; return { status, body }; } diff --git a/server/src/queries/asset.job.repository.sql b/server/src/queries/asset.job.repository.sql index 746ef6bfee..aa04603913 100644 --- a/server/src/queries/asset.job.repository.sql +++ b/server/src/queries/asset.job.repository.sql @@ -239,10 +239,68 @@ select "asset_edit"."assetId" = "asset"."id" ) as agg ) as "edits", - to_json("asset_exif") as "exifInfo" + to_json("asset_exif") as "exifInfo", + ( + select + to_json(obj) + from + ( + select + "asset_video"."index", + "asset_video"."codecName", + "asset_video"."profile", + "asset_video"."level", + "asset_video"."bitrate", + "asset_exif"."exifImageWidth" as "width", + "asset_exif"."exifImageHeight" as "height", + "asset_video"."pixelFormat", + "asset_video"."frameCount", + "asset_exif"."fps" as "frameRate", + "asset_video"."timeBase", + case + when "asset_exif"."orientation" = '6' then -90 + when "asset_exif"."orientation" = '8' then 90 + when "asset_exif"."orientation" = '3' then 180 + else 0 + end as "rotation", + "asset_video"."colorPrimaries", + "asset_video"."colorMatrix", + "asset_video"."colorTransfer", + "asset_video"."dvProfile", + "asset_video"."dvLevel", + "asset_video"."dvBlSignalCompatibilityId" + from + ( + select + 1 + ) as "dummy" + where + "asset_video"."assetId" is not null + ) as obj + ) as "videoStream", + ( + select + to_json(obj) + from + ( + select + "asset_video"."formatName", + "asset_video"."formatLongName", + "asset"."duration", + "asset_video"."bitrate" + from + ( + select + 1 + ) as "dummy" + where + "asset_video"."assetId" is not null + ) as obj + ) as "format" from "asset" inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" + left join "asset_video" on "asset_video"."assetId" = "asset"."id" where "asset"."id" = $4 @@ -554,9 +612,88 @@ select where "asset_file"."assetId" = "asset"."id" ) as agg - ) as "files" + ) as "files", + ( + select + to_json(obj) + from + ( + select + "asset_audio"."index", + "asset_audio"."codecName", + "asset_audio"."profile", + "asset_audio"."bitrate" + from + ( + select + 1 + ) as "dummy" + where + "asset_audio"."assetId" is not null + ) as obj + ) as "audioStream", + ( + select + to_json(obj) + from + ( + select + "asset_video"."index", + "asset_video"."codecName", + "asset_video"."profile", + "asset_video"."level", + "asset_video"."bitrate", + "asset_exif"."exifImageWidth" as "width", + "asset_exif"."exifImageHeight" as "height", + "asset_video"."pixelFormat", + "asset_video"."frameCount", + "asset_exif"."fps" as "frameRate", + "asset_video"."timeBase", + case + when "asset_exif"."orientation" = '6' then -90 + when "asset_exif"."orientation" = '8' then 90 + when "asset_exif"."orientation" = '3' then 180 + else 0 + end as "rotation", + "asset_video"."colorPrimaries", + "asset_video"."colorMatrix", + "asset_video"."colorTransfer", + "asset_video"."dvProfile", + "asset_video"."dvLevel", + "asset_video"."dvBlSignalCompatibilityId" + from + ( + select + 1 + ) as "dummy" + where + "asset_video"."assetId" is not null + ) as obj + ) as "videoStream", + ( + select + to_json(obj) + from + ( + select + "asset_video"."formatName", + "asset_video"."formatLongName", + "asset"."duration", + "asset_video"."bitrate" + from + ( + select + 1 + ) as "dummy" + where + "asset_video"."assetId" is not null + ) as obj + ) as "format" from "asset" + inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" + inner join "asset_video" on "asset_video"."assetId" = "asset"."id" + left join "asset_audio" on "asset_audio"."assetId" = "asset"."id" where "asset"."id" = $1 and "asset"."type" = 'VIDEO' diff --git a/server/src/repositories/asset-job.repository.ts b/server/src/repositories/asset-job.repository.ts index 3765cad7ed..bab0c44a41 100644 --- a/server/src/repositories/asset-job.repository.ts +++ b/server/src/repositories/asset-job.repository.ts @@ -9,6 +9,7 @@ import { DB } from 'src/schema'; import { anyUuid, asUuid, + withAudioStream, withDefaultVisibility, withEdits, withExif, @@ -16,6 +17,8 @@ import { withFaces, withFilePath, withFiles, + withVideoFormat, + withVideoStream, } from 'src/utils/database'; import { mimeTypes } from 'src/utils/mime-types'; @@ -134,6 +137,9 @@ export class AssetJobRepository { ) .select(withEdits) .$call(withExifInner) + .leftJoin('asset_video', 'asset_video.assetId', 'asset.id') + .select((eb) => withVideoStream(eb).as('videoStream')) + .select((eb) => withVideoFormat(eb).as('format')) .where('asset.id', '=', id) .executeTakeFirst(); } @@ -333,8 +339,14 @@ export class AssetJobRepository { getForVideoConversion(id: string) { return this.db .selectFrom('asset') + .innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId') + .innerJoin('asset_video', 'asset_video.assetId', 'asset.id') + .leftJoin('asset_audio', 'asset_audio.assetId', 'asset.id') .select(['asset.id', 'asset.ownerId', 'asset.originalPath']) .select(withFiles) + .select((eb) => withAudioStream(eb).as('audioStream')) + .select((eb) => withVideoStream(eb).$notNull().as('videoStream')) + .select((eb) => withVideoFormat(eb).$notNull().as('format')) .where('asset.id', '=', id) .where('asset.type', '=', sql.lit(AssetType.Video)) .executeTakeFirst(); diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 784cf68b5b..0b706cacf9 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -19,6 +19,7 @@ import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetFileType, AssetOrder, AssetStatus, AssetType, AssetVisibility } from 'src/enum'; import { DB } from 'src/schema'; +import { AssetAudioTable, AssetKeyframeTable, AssetVideoTable } from 'src/schema/tables/asset-av.table'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { AssetFileTable } from 'src/schema/tables/asset-file.table'; import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table'; @@ -124,6 +125,14 @@ interface GetByIdsRelations { edits?: boolean; } +type UpsertExifOptions = { + exif: Insertable; + audio?: Insertable; + video?: Insertable; + keyframes?: Insertable; + lockedPropertiesBehavior: 'override' | 'append' | 'skip'; +}; + const distinctLocked = (eb: ExpressionBuilder, columns: T) => sql`nullif(array(select distinct unnest(${eb.ref('asset_exif.lockedProperties')} || ${columns})), '{}')`; @@ -161,15 +170,76 @@ export class AssetRepository { @GenerateSql({ params: [ - { dateTimeOriginal: DummyValue.DATE, lockedProperties: ['dateTimeOriginal'] }, - { lockedPropertiesBehavior: 'append' }, + { + exif: { dateTimeOriginal: DummyValue.DATE, lockedProperties: ['dateTimeOriginal'] }, + lockedPropertiesBehavior: 'append', + }, ], }) - async upsertExif( - exif: Insertable, - { lockedPropertiesBehavior }: { lockedPropertiesBehavior: 'override' | 'append' | 'skip' }, - ): Promise { - await this.db + async upsertExif({ exif, audio, video, keyframes, lockedPropertiesBehavior }: UpsertExifOptions): Promise { + let query = this.db; + if (audio) { + (query as any) = this.db.with('audio', (qb) => + qb + .insertInto('asset_audio') + .values(audio) + .onConflict((oc) => + oc.column('assetId').doUpdateSet(({ ref }) => ({ + bitrate: ref('asset_audio.bitrate'), + index: ref('asset_audio.index'), + profile: ref('asset_audio.profile'), + codecName: ref('asset_audio.codecName'), + })), + ), + ); + } + + if (video) { + (query as any) = query.with('video', (qb) => + qb + .insertInto('asset_video') + .values(video) + .onConflict((oc) => + oc.column('assetId').doUpdateSet(({ ref }) => ({ + bitrate: ref('asset_video.bitrate'), + timeBase: ref('asset_video.timeBase'), + index: ref('asset_video.index'), + profile: ref('asset_video.profile'), + level: ref('asset_video.level'), + colorPrimaries: ref('asset_video.colorPrimaries'), + colorTransfer: ref('asset_video.colorTransfer'), + colorMatrix: ref('asset_video.colorMatrix'), + dvProfile: ref('asset_video.dvProfile'), + dvLevel: ref('asset_video.dvLevel'), + dvBlSignalCompatibilityId: ref('asset_video.dvBlSignalCompatibilityId'), + codecName: ref('asset_video.codecName'), + formatName: ref('asset_video.formatName'), + formatLongName: ref('asset_video.formatLongName'), + pixelFormat: ref('asset_video.pixelFormat'), + })), + ), + ); + } + + if (keyframes) { + (query as any) = query.with('keyframe', (qb) => + qb + .insertInto('asset_keyframe') + .values(keyframes) + .onConflict((oc) => + oc.column('assetId').doUpdateSet(({ ref }) => ({ + pts: ref('asset_keyframe.pts'), + accDuration: ref('asset_keyframe.accDuration'), + ownDuration: ref('asset_keyframe.ownDuration'), + totalDuration: ref('asset_keyframe.totalDuration'), + packetCount: ref('asset_keyframe.packetCount'), + outputFrames: ref('asset_keyframe.outputFrames'), + })), + ), + ); + } + + await query .insertInto('asset_exif') .values(exif) .onConflict((oc) => diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index 58e006171a..ce81e7752b 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -1,14 +1,30 @@ import { Injectable } from '@nestjs/common'; import { ExifDateTime, exiftool, WriteTags } from 'exiftool-vendored'; -import ffmpeg, { FfprobeData } from 'fluent-ffmpeg'; +import ffmpeg, { FfprobeData, FfprobeStream } from 'fluent-ffmpeg'; +import _ from 'lodash'; import { Duration } from 'luxon'; +import { execFile as execFileCb } from 'node:child_process'; import fs from 'node:fs/promises'; import { Writable } from 'node:stream'; +import { promisify } from 'node:util'; import sharp from 'sharp'; import { ORIENTATION_TO_SHARP_ROTATION } from 'src/constants'; import { Exif } from 'src/database'; import { AssetEditActionItem } from 'src/dtos/editing.dto'; -import { Colorspace, LogLevel, RawExtractedFormat } from 'src/enum'; +import { + AacProfile, + Av1Profile, + ColorMatrix, + ColorPrimaries, + Colorspace, + ColorTransfer, + DvProfile, + DvSignalCompatibility, + H264Profile, + HevcProfile, + LogLevel, + RawExtractedFormat, +} from 'src/enum'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { DecodeToBufferOptions, @@ -18,6 +34,7 @@ import { ProbeOptions, TranscodeCommand, VideoInfo, + VideoPacketInfo, } from 'src/types'; import { handlePromiseError } from 'src/utils/misc'; import { createAffineMatrix } from 'src/utils/transform'; @@ -26,9 +43,14 @@ const probe = (input: string, options: string[]): Promise => new Promise((resolve, reject) => ffmpeg.ffprobe(input, options, (error, data) => (error ? reject(error) : resolve(data))), ); + +const execFile = promisify(execFileCb); + sharp.concurrency(0); sharp.cache({ files: 0 }); +const pascalCase = (str: string) => _.upperFirst(_.camelCase(str.toLowerCase())); + type ProgressEvent = { frames: number; currentFps: number; @@ -244,6 +266,7 @@ export class MediaRepository { }, videoStreams: results.streams .filter((stream) => stream.codec_type === 'video' && !stream.disposition?.attached_pic) + .sort((a, b) => this.compareStreams(a, b)) .map((stream) => { const height = this.parseInt(stream.height); const dar = this.getDar(stream.display_aspect_ratio); @@ -252,28 +275,98 @@ export class MediaRepository { height, width: dar ? Math.round(height * dar) : this.parseInt(stream.width), codecName: stream.codec_name === 'h265' ? 'hevc' : stream.codec_name, - codecType: stream.codec_type, + profile: this.parseVideoProfile(stream.codec_name, stream.profile as string | undefined), + level: this.parseOptionalInt(stream.level), frameCount: this.parseInt(options?.countFrames ? stream.nb_read_packets : stream.nb_frames), + frameRate: this.parseFrameRate(stream.avg_frame_rate ?? stream.r_frame_rate), + timeBase: this.parseRational(stream.time_base)?.den, rotation: this.parseInt(stream.rotation), - isHDR: stream.color_transfer === 'smpte2084' || stream.color_transfer === 'arib-std-b67', bitrate: this.parseInt(stream.bit_rate), pixelFormat: stream.pix_fmt || 'yuv420p', - colorPrimaries: stream.color_primaries, - colorSpace: stream.color_space, - colorTransfer: stream.color_transfer, + colorPrimaries: this.parseEnum(ColorPrimaries, stream.color_primaries) ?? ColorPrimaries.Unknown, + colorMatrix: this.parseEnum(ColorMatrix, stream.color_space) ?? ColorMatrix.Unknown, + colorTransfer: this.parseEnum(ColorTransfer, stream.color_transfer) ?? ColorTransfer.Unknown, + dvProfile: this.parseOptionalInt(stream.dv_profile) as DvProfile | undefined, + dvLevel: this.parseOptionalInt(stream.dv_level), + dvBlSignalCompatibilityId: this.parseOptionalInt(stream.dv_bl_signal_compatibility_id) as + | DvSignalCompatibility + | undefined, }; }), audioStreams: results.streams .filter((stream) => stream.codec_type === 'audio') + .sort((a, b) => this.compareStreams(a, b)) .map((stream) => ({ index: stream.index, - codecType: stream.codec_type, codecName: stream.codec_name, + profile: + stream.codec_name === 'aac' ? this.parseEnum(AacProfile, stream.profile as string | undefined) : undefined, bitrate: this.parseInt(stream.bit_rate), })), }; } + /** + * Needed for accurate segments, especially when remuxing, seeking and/or VFR is involved. + * Scanning packets for keyframes in JS is much faster than -skip_frame nokey since it avoids decoding the video. + */ + async probePackets(input: string, streamIndex: number): Promise { + const { stdout } = await execFile('ffprobe', [ + '-v', + 'error', + '-select_streams', + String(streamIndex), + '-show_entries', + 'packet=pts,duration,flags', + '-of', + 'csv=p=0', + input, + ]); + + let totalDuration = 0; + const keyframePts: number[] = []; + const keyframeAccDuration: number[] = []; + const keyframeOwnDuration: number[] = []; + const postDiscard: { pts: number; duration: number }[] = []; + for (const line of stdout.split('\n')) { + if (!line) { + continue; + } + const [ptsStr, durationStr, flags] = line.split(','); + const pts = Number.parseInt(ptsStr); + const duration = Number.parseInt(durationStr); + if (Number.isNaN(pts) || Number.isNaN(duration)) { + continue; + } + // Discarded packets don't contribute to packet count, but still contribute to video duration + totalDuration += duration; + if (flags[1] !== 'D') { + postDiscard.push({ pts, duration }); + } + if (flags[0] === 'K') { + keyframePts.push(pts); + keyframeAccDuration.push(totalDuration); + // VFR content can have variable duration keyframes, + // so we need to track their duration separately for accurate segment boundaries. + // Non-keyframes are accounted for in totalDuration. + keyframeOwnDuration.push(duration); + } + } + + if (postDiscard.length === 0) { + return null; + } + + return { + totalDuration, + packetCount: postDiscard.length, + outputFrames: this.cfrOutputFrames(postDiscard, postDiscard.length / totalDuration), + keyframePts, + keyframeAccDuration, + keyframeOwnDuration, + }; + } + transcode(input: string, output: string | Writable, options: TranscodeCommand): Promise { if (!options.twoPass) { return new Promise((resolve, reject) => { @@ -356,6 +449,31 @@ export class MediaRepository { return Number.parseFloat(value as string) || 0; } + private parseOptionalInt(value: string | number | undefined): number | undefined { + const parsed = Number.parseInt(value as string); + return Number.isNaN(parsed) ? undefined : parsed; + } + + private parseEnum>(enumObj: E, value?: string) { + return value ? (enumObj[pascalCase(value)] as Extract | undefined) : undefined; + } + + /** Parse a rational like "60000/1001" or "1/600" into `{ num, den }`. */ + private parseRational(value: string | undefined): { num: number; den: number } | undefined { + if (!value) { + return; + } + const [num, den = 1] = value.split('/').map(Number); + if (num && den) { + return { num, den }; + } + } + + private parseFrameRate(value: string | undefined): number | undefined { + const r = this.parseRational(value); + return r ? r.num / r.den : undefined; + } + private getDar(dar: string | undefined): number { if (dar) { const [darW, darH] = dar.split(':').map(Number); @@ -366,4 +484,42 @@ export class MediaRepository { return 0; } + + private parseVideoProfile(codec?: string, profile?: string) { + switch (codec) { + case 'h264': { + return this.parseEnum(H264Profile, profile); + } + case 'h265': + case 'hevc': { + return this.parseEnum(HevcProfile, profile); + } + case 'av1': { + return this.parseEnum(Av1Profile, profile); + } + } + } + + private compareStreams(a: FfprobeStream, b: FfprobeStream): number { + const d = (b.disposition?.default ?? 0) - (a.disposition?.default ?? 0); + if (d !== 0) { + return d; + } + return this.parseInt(b.bit_rate) - this.parseInt(a.bit_rate); + } + + private cfrOutputFrames(packets: { pts: number; duration: number }[], slotsPerTick: number) { + // Packets may be out of PTS order due to B-frames + packets.sort((a, b) => a.pts - b.pts); + const firstPts = packets[0].pts; + let outputFrames = 0; + let nextPts = 0; + for (const pkt of packets) { + const delta = (pkt.pts - firstPts) * slotsPerTick - nextPts + pkt.duration * slotsPerTick; + const nb = delta < -1.1 ? 0 : delta > 1.1 ? Math.round(delta) : 1; + outputFrames += nb; + nextPts += nb; + } + return outputFrames; + } } diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index f5a26cf7f6..9b1b6ec7d2 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -34,6 +34,7 @@ import { AlbumUserTable } from 'src/schema/tables/album-user.table'; import { AlbumTable } from 'src/schema/tables/album.table'; import { ApiKeyTable } from 'src/schema/tables/api-key.table'; import { AssetAuditTable } from 'src/schema/tables/asset-audit.table'; +import { AssetAudioTable, AssetKeyframeTable, AssetVideoTable } from 'src/schema/tables/asset-av.table'; import { AssetEditAuditTable } from 'src/schema/tables/asset-edit-audit.table'; import { AssetEditTable } from 'src/schema/tables/asset-edit.table'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; @@ -209,6 +210,9 @@ export interface DB { asset_job_status: AssetJobStatusTable; asset_ocr: AssetOcrTable; asset_ocr_audit: AssetOcrAuditTable; + asset_audio: AssetAudioTable; + asset_video: AssetVideoTable; + asset_keyframe: AssetKeyframeTable; ocr_search: OcrSearchTable; face_search: FaceSearchTable; diff --git a/server/src/schema/migrations/1777654048096-CreateAudioVideoTables.ts b/server/src/schema/migrations/1777654048096-CreateAudioVideoTables.ts new file mode 100644 index 0000000000..2e0bf36bb0 --- /dev/null +++ b/server/src/schema/migrations/1777654048096-CreateAudioVideoTables.ts @@ -0,0 +1,51 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE TABLE "asset_audio" ( + "assetId" uuid NOT NULL, + "bitrate" integer NOT NULL, + "index" smallint NOT NULL, + "profile" smallint, + "codecName" text NOT NULL, + CONSTRAINT "asset_audio_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "asset" ("id") ON UPDATE NO ACTION ON DELETE CASCADE, + CONSTRAINT "asset_audio_pkey" PRIMARY KEY ("assetId") +);`.execute(db); + await sql`CREATE TABLE "asset_video" ( + "assetId" uuid NOT NULL, + "bitrate" integer NOT NULL, + "frameCount" integer NOT NULL, + "timeBase" integer NOT NULL, + "index" smallint NOT NULL, + "profile" smallint, + "level" smallint, + "colorPrimaries" smallint NOT NULL, + "colorTransfer" smallint NOT NULL, + "colorMatrix" smallint NOT NULL, + "dvProfile" smallint, + "dvLevel" smallint, + "dvBlSignalCompatibilityId" smallint, + "codecName" text NOT NULL, + "formatName" text NOT NULL, + "formatLongName" text NOT NULL, + "pixelFormat" text NOT NULL, + CONSTRAINT "asset_video_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "asset" ("id") ON UPDATE NO ACTION ON DELETE CASCADE, + CONSTRAINT "asset_video_pkey" PRIMARY KEY ("assetId") +);`.execute(db); + await sql`CREATE TABLE "asset_keyframe" ( + "assetId" uuid NOT NULL, + "pts" integer[] NOT NULL, + "accDuration" integer[] NOT NULL, + "ownDuration" integer[] NOT NULL, + "totalDuration" integer NOT NULL, + "packetCount" integer NOT NULL, + "outputFrames" integer NOT NULL, + CONSTRAINT "asset_keyframe_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "asset" ("id") ON UPDATE NO ACTION ON DELETE CASCADE, + CONSTRAINT "asset_keyframe_pkey" PRIMARY KEY ("assetId") +);`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP TABLE "asset_audio";`.execute(db); + await sql`DROP TABLE "asset_video";`.execute(db); + await sql`DROP TABLE "asset_keyframe";`.execute(db); +} diff --git a/server/src/schema/migrations/1776735180298-ChangeDurationToInteger.ts b/server/src/schema/migrations/1777667825574-ChangeDurationToInteger.ts similarity index 100% rename from server/src/schema/migrations/1776735180298-ChangeDurationToInteger.ts rename to server/src/schema/migrations/1777667825574-ChangeDurationToInteger.ts diff --git a/server/src/schema/tables/asset-av.table.ts b/server/src/schema/tables/asset-av.table.ts new file mode 100644 index 0000000000..41824e2509 --- /dev/null +++ b/server/src/schema/tables/asset-av.table.ts @@ -0,0 +1,98 @@ +import { Column, ForeignKeyColumn, Table } from '@immich/sql-tools'; +import { AssetTable } from 'src/schema/tables/asset.table'; + +@Table('asset_audio') +export class AssetAudioTable { + @ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', primary: true }) + assetId!: string; + + @Column({ type: 'integer' }) + bitrate!: number; + + @Column({ type: 'smallint' }) + index!: number; + + @Column({ type: 'smallint', nullable: true }) + profile!: number | null; + + @Column({ type: 'text' }) + codecName!: string; +} + +@Table('asset_video') +export class AssetVideoTable { + @ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', primary: true }) + assetId!: string; + + @Column({ type: 'integer' }) + bitrate!: number; + + @Column({ type: 'integer' }) + frameCount!: number; + + @Column({ type: 'integer' }) + timeBase!: number; + + @Column({ type: 'smallint' }) + index!: number; + + @Column({ type: 'smallint', nullable: true }) + profile!: number | null; + + @Column({ type: 'smallint', nullable: true }) + level!: number | null; + + @Column({ type: 'smallint' }) + colorPrimaries!: number; + + @Column({ type: 'smallint' }) + colorTransfer!: number; + + @Column({ type: 'smallint' }) + colorMatrix!: number; + + @Column({ type: 'smallint', nullable: true }) + dvProfile!: number | null; + + @Column({ type: 'smallint', nullable: true }) + dvLevel!: number | null; + + @Column({ type: 'smallint', nullable: true }) + dvBlSignalCompatibilityId!: number | null; + + @Column({ type: 'text' }) + codecName!: string; + + @Column({ type: 'text' }) + formatName!: string; + + @Column({ type: 'text' }) + formatLongName!: string; + + @Column({ type: 'text' }) + pixelFormat!: string; +} + +@Table('asset_keyframe') +export class AssetKeyframeTable { + @ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', primary: true }) + assetId!: string; + + @Column({ type: 'integer', array: true }) + pts!: number[]; + + @Column({ type: 'integer', array: true }) + accDuration!: number[]; + + @Column({ type: 'integer', array: true }) + ownDuration!: number[]; + + @Column({ type: 'integer' }) + totalDuration!: number; + + @Column({ type: 'integer' }) + packetCount!: number; + + @Column({ type: 'integer' }) + outputFrames!: number; +} diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index 24b0668eb2..7bfc0bdcc2 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -107,7 +107,8 @@ export class AlbumService extends BaseService { for (const { userId } of albumUsers) { const exists = await this.userRepository.get(userId, {}); if (!exists) { - throw new BadRequestException('User not found'); + this.logger.debug('Album creation failed: user not found'); + throw new BadRequestException('Invalid user'); } if (userId == auth.user.id) { @@ -302,7 +303,8 @@ export class AlbumService extends BaseService { const user = await this.userRepository.get(userId, {}); if (!user) { - throw new BadRequestException('User not found'); + this.logger.debug('Adding user to album failed: user not found'); + throw new BadRequestException('Invalid user'); } await this.albumUserRepository.create({ userId, albumId: id, role }); diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 74aaa8fcbd..6b0d73b77b 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -351,10 +351,10 @@ export class AssetMediaService extends BaseService { await this.storageRepository.utimes(sidecarFile.originalPath, new Date(), new Date(dto.fileModifiedAt)); } await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt)); - await this.assetRepository.upsertExif( - { assetId: asset.id, fileSizeInByte: file.size }, - { lockedPropertiesBehavior: 'override' }, - ); + await this.assetRepository.upsertExif({ + exif: { assetId: asset.id, fileSizeInByte: file.size }, + lockedPropertiesBehavior: 'override', + }); await this.eventRepository.emit('AssetCreate', { asset }); diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 13462a3246..75e7fc5e87 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -187,8 +187,10 @@ describe(AssetService.name, () => { await sut.update(authStub.admin, asset.id, { description: 'Test description' }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( - { assetId: asset.id, description: 'Test description', lockedProperties: ['description'] }, - { lockedPropertiesBehavior: 'append' }, + expect.objectContaining({ + exif: { assetId: asset.id, description: 'Test description', lockedProperties: ['description'] }, + lockedPropertiesBehavior: 'append', + }), ); }); @@ -201,12 +203,14 @@ describe(AssetService.name, () => { await sut.update(authStub.admin, asset.id, { rating: 3 }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( - { - assetId: asset.id, - rating: 3, - lockedProperties: ['rating'], - }, - { lockedPropertiesBehavior: 'append' }, + expect.objectContaining({ + exif: { + assetId: asset.id, + rating: 3, + lockedProperties: ['rating'], + }, + lockedPropertiesBehavior: 'append', + }), ); }); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 613029fe3c..e2d2d95f81 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -517,13 +517,13 @@ export class AssetService extends BaseService { ); if (Object.keys(writes).length > 0) { - await this.assetRepository.upsertExif( - updateLockedColumns({ + await this.assetRepository.upsertExif({ + exif: updateLockedColumns({ assetId: id, ...writes, }), - { lockedPropertiesBehavior: 'append' }, - ); + lockedPropertiesBehavior: 'append', + }); await this.jobRepository.queue({ name: JobName.SidecarWrite, data: { id } }); } } diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 628e863712..5323252738 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -2,7 +2,7 @@ import { BadRequestException, ForbiddenException, Injectable, UnauthorizedExcept import { parse } from 'cookie'; import { DateTime } from 'luxon'; import { IncomingHttpHeaders } from 'node:http'; -import { LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants'; +import { LOGIN_DUMMY_HASH, LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants'; import { AuthSharedLink, AuthUser, UserAdmin } from 'src/database'; import { AuthDto, @@ -62,15 +62,12 @@ export class AuthService extends BaseService { throw new UnauthorizedException('Password login has been disabled'); } - let user = await this.userRepository.getByEmail(dto.email, { withPassword: true }); - if (user) { - const isAuthenticated = this.validateSecret(dto.password, user.password); - if (!isAuthenticated) { - user = undefined; - } - } + const user = await this.userRepository.getByEmail(dto.email, { withPassword: true }); + // Always run bcrypt so response time is constant regardless of whether the email + // is registered, preventing timing-based user enumeration. + const authenticated = this.cryptoRepository.compareBcrypt(dto.password, user?.password ?? LOGIN_DUMMY_HASH); - if (!user) { + if (!user || !user.password || !authenticated) { this.logger.warn(`Failed login attempt for user ${dto.email} from ip address ${details.clientIp}`); throw new UnauthorizedException('Incorrect email or password'); } @@ -325,7 +322,8 @@ export class AuthService extends BaseService { const emailUser = await this.userRepository.getByEmail(normalizedEmail); if (emailUser) { if (emailUser.oauthId) { - throw new BadRequestException('User already exists, but is linked to another account.'); + this.logger.debug('OAuth login conflict: email already linked to different account'); + throw new BadRequestException('OAuth authentication failed'); } user = await this.userRepository.update(emailUser.id, { oauthId: profile.sub }); } @@ -335,9 +333,9 @@ export class AuthService extends BaseService { if (!user) { if (!autoRegister) { this.logger.warn( - `Unable to register ${profile.sub}/${normalizedEmail || '(no email)'}. To enable set OAuth Auto Register to true in admin settings.`, + `Unable to register ${profile.sub}/${normalizedEmail || '(no email)'}. User does not exist and auto registering is disabled. To enable set OAuth Auto Register to true in admin settings.`, ); - throw new BadRequestException(`User does not exist and auto registering is disabled.`); + throw new BadRequestException('OAuth authentication failed'); } if (!normalizedEmail) { diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index dc402592cd..d930dd0a31 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -218,7 +218,8 @@ export class BaseService { async createUser(dto: Insertable & { email: string }): Promise { const exists = await this.userRepository.getByEmail(dto.email); if (exists) { - throw new BadRequestException('User exists'); + this.logger.debug('User creation rejected: user already exists'); + throw new BadRequestException('Email is not available'); } if (!dto.isAdmin) { diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 61940dd91d..cb29598c10 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -1,4 +1,4 @@ -import { ShallowDehydrateObject } from 'kysely'; +import { NotNull, ShallowDehydrateObject } from 'kysely'; import { OutputInfo } from 'sharp'; import { SystemConfig } from 'src/config'; import { Exif } from 'src/database'; @@ -21,7 +21,7 @@ import { VideoCodec, } from 'src/enum'; import { MediaService } from 'src/services/media.service'; -import { JobCounts, RawImageInfo } from 'src/types'; +import { AudioStreamInfo, JobCounts, RawImageInfo, VideoFormat, VideoStreamInfo } from 'src/types'; import { AssetFaceFactory } from 'test/factories/asset-face.factory'; import { AssetFactory } from 'test/factories/asset.factory'; import { PersonFactory } from 'test/factories/person.factory'; @@ -375,15 +375,16 @@ describe(MediaService.name, () => { mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); await expect(sut.handleGenerateThumbnails({ id: asset.id })).resolves.toBe(JobStatus.Skipped); - expect(mocks.media.probe).not.toHaveBeenCalled(); expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); expect(mocks.asset.update).not.toHaveBeenCalledWith(); }); it('should skip video thumbnail generation if no video stream', async () => { const asset = AssetFactory.from({ type: AssetType.Video }).exif().build(); - mocks.media.probe.mockResolvedValue(probeStub.noVideoStreams); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ + ...getForGenerateThumbnail(asset), + ...probeStub.noVideoStreams, + }); await expect(sut.handleGenerateThumbnails({ id: asset.id })).rejects.toThrowError(); expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); expect(mocks.asset.update).not.toHaveBeenCalledWith(); @@ -495,8 +496,10 @@ describe(MediaService.name, () => { it('should generate a thumbnail for a video', async () => { const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build(); - mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ + ...getForGenerateThumbnail(asset), + ...probeStub.videoStream2160p, + }); await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String)); @@ -505,7 +508,7 @@ describe(MediaService.name, () => { expect.any(String), expect.objectContaining({ inputOptions: ['-skip_frame', 'nointra', '-sws_flags', 'accurate_rnd+full_chroma_int'], - outputOptions: [ + outputOptions: expect.arrayContaining([ '-fps_mode', 'vfr', '-frames:v', @@ -516,7 +519,7 @@ describe(MediaService.name, () => { 'verbose', '-vf', String.raw`fps=12:start_time=0:eof_action=pass:round=down,thumbnail=12,select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20),trim=end_frame=2,reverse,scale=-2:1440:flags=lanczos+accurate_rnd+full_chroma_int:out_range=pc`, - ], + ]), twoPass: false, }), ); @@ -542,8 +545,10 @@ describe(MediaService.name, () => { it('should tonemap thumbnail for hdr video', async () => { const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build(); - mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ + ...getForGenerateThumbnail(asset), + ...probeStub.videoStreamHDR, + }); await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String)); @@ -552,7 +557,7 @@ describe(MediaService.name, () => { expect.any(String), expect.objectContaining({ inputOptions: ['-skip_frame', 'nointra', '-sws_flags', 'accurate_rnd+full_chroma_int'], - outputOptions: [ + outputOptions: expect.arrayContaining([ '-fps_mode', 'vfr', '-frames:v', @@ -562,8 +567,8 @@ describe(MediaService.name, () => { '-v', 'verbose', '-vf', - String.raw`fps=12:start_time=0:eof_action=pass:round=down,thumbnail=12,select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20),trim=end_frame=2,reverse,tonemapx=tonemap=hable:desat=0:p=bt709:t=bt709:m=bt709:r=pc:peak=100:format=yuv420p`, - ], + String.raw`fps=12:start_time=0:eof_action=pass:round=down,thumbnail=12,select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20),trim=end_frame=2,reverse,scale=-2:250:flags=lanczos+accurate_rnd+full_chroma_int:out_range=pc,tonemapx=tonemap=hable:desat=0:p=bt709:t=bt709:m=bt709:r=pc:peak=100:format=yuv420p`, + ]), twoPass: false, }), ); @@ -589,11 +594,13 @@ describe(MediaService.name, () => { it('should always generate video thumbnail in one pass', async () => { const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build(); - mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { twoPass: true, maxBitrate: '5000k' }, }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ + ...getForGenerateThumbnail(asset), + ...probeStub.videoStreamHDR, + }); await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -601,7 +608,7 @@ describe(MediaService.name, () => { expect.any(String), expect.objectContaining({ inputOptions: ['-skip_frame', 'nointra', '-sws_flags', 'accurate_rnd+full_chroma_int'], - outputOptions: [ + outputOptions: expect.arrayContaining([ '-fps_mode', 'vfr', '-frames:v', @@ -611,8 +618,8 @@ describe(MediaService.name, () => { '-v', 'verbose', '-vf', - String.raw`fps=12:start_time=0:eof_action=pass:round=down,thumbnail=12,select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20),trim=end_frame=2,reverse,tonemapx=tonemap=hable:desat=0:p=bt709:t=bt709:m=bt709:r=pc:peak=100:format=yuv420p`, - ], + String.raw`fps=12:start_time=0:eof_action=pass:round=down,thumbnail=12,select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20),trim=end_frame=2,reverse,scale=-2:250:flags=lanczos+accurate_rnd+full_chroma_int:out_range=pc,tonemapx=tonemap=hable:desat=0:p=bt709:t=bt709:m=bt709:r=pc:peak=100:format=yuv420p`, + ]), twoPass: false, }), ); @@ -620,8 +627,10 @@ describe(MediaService.name, () => { it('should not skip intra frames for MTS file', async () => { const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build(); - mocks.media.probe.mockResolvedValue(probeStub.videoStreamMTS); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ + ...getForGenerateThumbnail(asset), + ...probeStub.videoStreamMTS, + }); await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -638,8 +647,10 @@ describe(MediaService.name, () => { it('should override reserved color metadata', async () => { const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build(); - mocks.media.probe.mockResolvedValue(probeStub.videoStreamReserved); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ + ...getForGenerateThumbnail(asset), + ...probeStub.videoStreamReserved, + }); await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -647,7 +658,7 @@ describe(MediaService.name, () => { expect.any(String), expect.objectContaining({ inputOptions: expect.arrayContaining([ - '-bsf:v', + '-bsf:0', 'hevc_metadata=colour_primaries=1:matrix_coefficients=1:transfer_characteristics=1', ]), outputOptions: expect.any(Array), @@ -659,9 +670,11 @@ describe(MediaService.name, () => { it('should use scaling divisible by 2 even when using quick sync', async () => { const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build(); - mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv } }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ + ...getForGenerateThumbnail(asset), + ...probeStub.videoStream2160p, + }); await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -855,11 +868,13 @@ describe(MediaService.name, () => { it('should never set isProgressive for videos', async () => { const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build(); - mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); mocks.systemMetadata.get.mockResolvedValue({ image: { preview: { progressive: true }, thumbnail: { progressive: true } }, }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ + ...getForGenerateThumbnail(asset), + ...probeStub.videoStreamHDR, + }); await sut.handleGenerateThumbnails({ id: asset.id }); @@ -1921,26 +1936,33 @@ describe(MediaService.name, () => { }); describe('handleVideoConversion', () => { + let asset: ReturnType & { + videoStream: VideoStreamInfo & { timeBase: NotNull }; + audioStream: AudioStreamInfo | null; + format: VideoFormat; + }; beforeEach(() => { - const asset = AssetFactory.create({ id: 'video-id', type: AssetType.Video, originalPath: '/original/path.ext' }); + asset = { + ...AssetFactory.create({ id: 'video-id', type: AssetType.Video, originalPath: '/original/path.ext' }), + videoStream: probeStub.videoStreamH264.videoStream, + audioStream: null, + format: probeStub.videoStreamH264.format, + }; mocks.assetJob.getForVideoConversion.mockResolvedValue(asset); sut.videoInterfaces = { dri: ['renderD128'], mali: true }; }); it('should skip transcoding if asset not found', async () => { - mocks.assetJob.getForVideoConversion.mockResolvedValue(void 0); await sut.handleVideoConversion({ id: 'video-id' }); - expect(mocks.media.probe).not.toHaveBeenCalled(); expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should transcode the highest bitrate video stream', async () => { mocks.logger.isLevelEnabled.mockReturnValue(false); - mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.multipleVideoStreams }); await sut.handleVideoConversion({ id: 'video-id' }); - expect(mocks.media.probe).toHaveBeenCalledWith('/original/path.ext', { countFrames: false }); expect(mocks.systemMetadata.get).toHaveBeenCalled(); expect(mocks.storage.mkdirSync).toHaveBeenCalled(); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -1956,11 +1978,10 @@ describe(MediaService.name, () => { it('should transcode the highest bitrate audio stream', async () => { mocks.logger.isLevelEnabled.mockReturnValue(false); - mocks.media.probe.mockResolvedValue(probeStub.multipleAudioStreams); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.multipleAudioStreams }); await sut.handleVideoConversion({ id: 'video-id' }); - expect(mocks.media.probe).toHaveBeenCalledWith('/original/path.ext', { countFrames: false }); expect(mocks.systemMetadata.get).toHaveBeenCalled(); expect(mocks.storage.mkdirSync).toHaveBeenCalled(); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -1975,19 +1996,19 @@ describe(MediaService.name, () => { }); it('should skip a video without any streams', async () => { - mocks.media.probe.mockResolvedValue(probeStub.noVideoStreams); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.noVideoStreams }); await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should skip a video without any height', async () => { - mocks.media.probe.mockResolvedValue(probeStub.noHeight); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.noHeight }); await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should throw an error if an unknown transcode policy is configured', async () => { - mocks.media.probe.mockResolvedValue(probeStub.noAudioStreams); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.noAudioStreams }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: 'foo' } } as never as SystemConfig); await expect(sut.handleVideoConversion({ id: 'video-id' })).rejects.toThrowError(); @@ -1995,7 +2016,7 @@ describe(MediaService.name, () => { }); it('should throw an error if transcoding fails and hw acceleration is disabled', async () => { - mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.multipleVideoStreams }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.All, accel: TranscodeHardwareAcceleration.Disabled }, }); @@ -2006,7 +2027,7 @@ describe(MediaService.name, () => { }); it('should transcode when set to all', async () => { - mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.multipleVideoStreams }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.All } }); await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -2021,7 +2042,7 @@ describe(MediaService.name, () => { }); it('should transcode when optimal and too big', async () => { - mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.videoStream2160p }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Optimal } }); await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -2036,14 +2057,14 @@ describe(MediaService.name, () => { }); it('should not transcode when policy bitrate and bitrate lower than max bitrate', async () => { - mocks.media.probe.mockResolvedValue(probeStub.videoStream40Mbps); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.videoStream40Mbps }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Bitrate, maxBitrate: '50M' } }); await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should transcode when policy bitrate and bitrate higher than max bitrate', async () => { - mocks.media.probe.mockResolvedValue(probeStub.videoStream40Mbps); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.videoStream40Mbps }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Bitrate, maxBitrate: '30M' } }); await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -2058,21 +2079,21 @@ describe(MediaService.name, () => { }); it('should not transcode when max bitrate is not a number', async () => { - mocks.media.probe.mockResolvedValue(probeStub.videoStream40Mbps); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.videoStream40Mbps }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Bitrate, maxBitrate: 'foo' } }); await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should not transcode when max bitrate is 0', async () => { - mocks.media.probe.mockResolvedValue(probeStub.videoStream40Mbps); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.videoStream40Mbps }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Bitrate, maxBitrate: '0' } }); await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should not scale resolution if no target resolution', async () => { - mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.videoStream2160p }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.All, targetResolution: 'original' }, }); @@ -2089,7 +2110,7 @@ describe(MediaService.name, () => { }); it('should scale horizontally when video is horizontal', async () => { - mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.videoStream2160p }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Optimal } }); await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -2104,7 +2125,7 @@ describe(MediaService.name, () => { }); it('should scale vertically when video is vertical', async () => { - mocks.media.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.videoStreamVertical2160p }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Optimal } }); await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -2119,7 +2140,7 @@ describe(MediaService.name, () => { }); it('should always scale video if height is uneven', async () => { - mocks.media.probe.mockResolvedValue(probeStub.videoStreamOddHeight); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.videoStreamOddHeight }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.All, targetResolution: 'original' }, }); @@ -2136,7 +2157,7 @@ describe(MediaService.name, () => { }); it('should always scale video if width is uneven', async () => { - mocks.media.probe.mockResolvedValue(probeStub.videoStreamOddWidth); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.videoStreamOddWidth }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.All, targetResolution: 'original' }, }); @@ -2153,7 +2174,7 @@ describe(MediaService.name, () => { }); it('should copy video stream when video matches target', async () => { - mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.Hevc, acceptedAudioCodecs: [AudioCodec.Aac] }, }); @@ -2170,7 +2191,7 @@ describe(MediaService.name, () => { }); it('should not include hevc tag when target is hevc and video stream is copied from a different codec', async () => { - mocks.media.probe.mockResolvedValue(probeStub.videoStreamH264); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.videoStreamH264 }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.Hevc, @@ -2191,7 +2212,7 @@ describe(MediaService.name, () => { }); it('should include hevc tag when target is hevc and copying hevc video stream', async () => { - mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.Hevc, @@ -2212,7 +2233,7 @@ describe(MediaService.name, () => { }); it('should copy audio stream when audio matches target', async () => { - mocks.media.probe.mockResolvedValue(probeStub.audioStreamAac); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.audioStreamAac }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Optimal } }); await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -2227,7 +2248,7 @@ describe(MediaService.name, () => { }); it('should remux when input is not an accepted container', async () => { - mocks.media.probe.mockResolvedValue(probeStub.videoStreamAvi); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.videoStreamAvi }); await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -2241,7 +2262,7 @@ describe(MediaService.name, () => { }); it('should throw an exception if transcode value is invalid', async () => { - mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.videoStream2160p }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: 'invalid' as any } }); await expect(sut.handleVideoConversion({ id: 'video-id' })).rejects.toThrowError(); @@ -2249,35 +2270,34 @@ describe(MediaService.name, () => { }); it('should not transcode if transcoding is disabled', async () => { - mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.videoStream2160p }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Disabled } }); await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should not remux when input is not an accepted container and transcoding is disabled', async () => { - mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Disabled } }); await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should not transcode if target codec is invalid', async () => { - mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.videoStream2160p }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: 'invalid' as any } }); await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should delete existing transcode if current policy does not require transcoding', async () => { - const asset = AssetFactory.from({ type: AssetType.Video }) + const localAsset = AssetFactory.from({ type: AssetType.Video }) .file({ type: AssetFileType.EncodedVideo, path: '/encoded/video/path.mp4' }) .build(); - mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Disabled } }); - mocks.assetJob.getForVideoConversion.mockResolvedValue(asset); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...localAsset, ...probeStub.videoStream2160p }); - await sut.handleVideoConversion({ id: asset.id }); + await sut.handleVideoConversion({ id: localAsset.id }); expect(mocks.media.transcode).not.toHaveBeenCalled(); expect(mocks.job.queue).toHaveBeenCalledWith({ @@ -2287,7 +2307,7 @@ describe(MediaService.name, () => { }); it('should set max bitrate if above 0', async () => { - mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { maxBitrate: '4500k' } }); await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -2302,7 +2322,7 @@ describe(MediaService.name, () => { }); it('should default max bitrate to kbps if no unit is provided', async () => { - mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { maxBitrate: '4500' } }); await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -2317,7 +2337,7 @@ describe(MediaService.name, () => { }); it('should transcode in two passes for h264/h265 when enabled and max bitrate is above 0', async () => { - mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { twoPass: true, maxBitrate: '4500k' } }); await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -2341,7 +2361,7 @@ describe(MediaService.name, () => { }); it('should fallback to one pass for h264/h265 if two-pass is enabled but no max bitrate is set', async () => { - mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { twoPass: true } }); await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -2356,7 +2376,7 @@ describe(MediaService.name, () => { }); it('should transcode by bitrate in two passes for vp9 when two pass mode and max bitrate are enabled', async () => { - mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { maxBitrate: '4500k', @@ -2377,7 +2397,7 @@ describe(MediaService.name, () => { }); it('should transcode by crf in two passes for vp9 when two pass mode is enabled and max bitrate is disabled', async () => { - mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { maxBitrate: '0', @@ -2398,7 +2418,7 @@ describe(MediaService.name, () => { }); it('should configure preset for vp9', async () => { - mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.Vp9, preset: 'slow' } }); await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -2413,7 +2433,7 @@ describe(MediaService.name, () => { }); it('should not configure preset for vp9 if invalid', async () => { - mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { preset: 'invalid', targetVideoCodec: VideoCodec.Vp9 } }); await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -2428,7 +2448,7 @@ describe(MediaService.name, () => { }); it('should configure threads if above 0', async () => { - mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.Vp9, threads: 2 } }); await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -2443,7 +2463,7 @@ describe(MediaService.name, () => { }); it('should disable thread pooling for h264 if thread limit is 1', async () => { - mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { threads: 1 } }); await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -2458,7 +2478,7 @@ describe(MediaService.name, () => { }); it('should omit thread flags for h264 if thread limit is at or below 0', async () => { - mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { threads: 0 } }); await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -2473,7 +2493,7 @@ describe(MediaService.name, () => { }); it('should disable thread pooling for hevc if thread limit is 1', async () => { - mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.videoStreamVp9 }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { threads: 1, targetVideoCodec: VideoCodec.Hevc } }); await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -2495,7 +2515,7 @@ describe(MediaService.name, () => { }); it('should omit thread flags for hevc if thread limit is at or below 0', async () => { - mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.videoStreamVp9 }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { threads: 0, targetVideoCodec: VideoCodec.Hevc } }); await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -2510,7 +2530,7 @@ describe(MediaService.name, () => { }); it('should use av1 if specified', async () => { - mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.videoStreamVp9 }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.Av1 } }); await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -2544,7 +2564,7 @@ describe(MediaService.name, () => { }); it('should map `veryslow` preset to 4 for av1', async () => { - mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.videoStreamVp9 }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.Av1, preset: 'veryslow' } }); await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -2559,7 +2579,7 @@ describe(MediaService.name, () => { }); it('should set max bitrate for av1 if specified', async () => { - mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.videoStreamVp9 }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.Av1, maxBitrate: '2M' } }); await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -2574,7 +2594,7 @@ describe(MediaService.name, () => { }); it('should set threads for av1 if specified', async () => { - mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.videoStreamVp9 }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.Av1, threads: 4 } }); await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -2589,7 +2609,7 @@ describe(MediaService.name, () => { }); it('should set both bitrate and threads for av1 if specified', async () => { - mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.videoStreamVp9 }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.Av1, threads: 4, maxBitrate: '2M' }, }); @@ -2606,7 +2626,7 @@ describe(MediaService.name, () => { }); it('should skip transcoding for audioless videos with optimal policy if video codec is correct', async () => { - mocks.media.probe.mockResolvedValue(probeStub.noAudioStreams); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.noAudioStreams }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.Hevc, @@ -2635,15 +2655,15 @@ describe(MediaService.name, () => { }); }); - it.each(acceptedCodecs)('should skip $codec', async ({ probeStub }) => { - mocks.media.probe.mockResolvedValue(probeStub); + it.each(acceptedCodecs)('should skip $codec', async ({ probeStub: stub }) => { + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...stub }); await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).not.toHaveBeenCalled(); }); }); it('should use libopus audio encoder when target audio is opus', async () => { - mocks.media.probe.mockResolvedValue(probeStub.audioStreamAac); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.audioStreamAac }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetAudioCodec: AudioCodec.Opus, @@ -2663,7 +2683,7 @@ describe(MediaService.name, () => { }); it('should fail if hwaccel is enabled for an unsupported codec', async () => { - mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Nvenc, targetVideoCodec: VideoCodec.Vp9 }, }); @@ -2672,14 +2692,14 @@ describe(MediaService.name, () => { }); it('should fail if hwaccel option is invalid', async () => { - mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: 'invalid' as any } }); await expect(sut.handleVideoConversion({ id: 'video-id' })).rejects.toThrowError(); expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should set options for nvenc', async () => { - mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Nvenc } }); await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -2725,7 +2745,7 @@ describe(MediaService.name, () => { }); it('should set two pass options for nvenc when enabled', async () => { - mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Nvenc, @@ -2746,7 +2766,7 @@ describe(MediaService.name, () => { }); it('should set vbr options for nvenc when max bitrate is enabled', async () => { - mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Nvenc, maxBitrate: '10000k' }, }); @@ -2763,7 +2783,7 @@ describe(MediaService.name, () => { }); it('should set cq options for nvenc when max bitrate is disabled', async () => { - mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Nvenc, maxBitrate: '10000k' }, }); @@ -2780,7 +2800,7 @@ describe(MediaService.name, () => { }); it('should omit preset for nvenc if invalid', async () => { - mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Nvenc, preset: 'invalid' }, }); @@ -2797,7 +2817,7 @@ describe(MediaService.name, () => { }); it('should ignore two pass for nvenc if max bitrate is disabled', async () => { - mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Nvenc } }); await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -2812,7 +2832,7 @@ describe(MediaService.name, () => { }); it('should use hardware decoding for nvenc if enabled', async () => { - mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Nvenc, accelDecode: true }, }); @@ -2837,7 +2857,7 @@ describe(MediaService.name, () => { }); it('should use hardware tone-mapping for nvenc if hardware decoding is enabled and should tone map', async () => { - mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.videoStreamHDR }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Nvenc, accelDecode: true }, }); @@ -2858,7 +2878,7 @@ describe(MediaService.name, () => { }); it('should set format to nv12 for nvenc if input is not yuv420p', async () => { - mocks.media.probe.mockResolvedValue(probeStub.videoStream10Bit); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.videoStream10Bit }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Nvenc, accelDecode: true }, }); @@ -2875,7 +2895,7 @@ describe(MediaService.name, () => { }); it('should set options for qsv', async () => { - mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv, maxBitrate: '10000k' }, }); @@ -2928,7 +2948,7 @@ describe(MediaService.name, () => { }); it('should set options for qsv with custom dri node', async () => { - mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv, @@ -2954,7 +2974,7 @@ describe(MediaService.name, () => { }); it('should omit preset for qsv if invalid', async () => { - mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv, preset: 'invalid' }, }); @@ -2976,7 +2996,7 @@ describe(MediaService.name, () => { }); it('should set low power mode for qsv if target video codec is vp9', async () => { - mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv, targetVideoCodec: VideoCodec.Vp9 }, }); @@ -2999,7 +3019,7 @@ describe(MediaService.name, () => { it('should fail for qsv if no hw devices', async () => { sut.videoInterfaces = { dri: [], mali: false }; - mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv } }); await expect(sut.handleVideoConversion({ id: 'video-id' })).rejects.toThrowError(); @@ -3009,7 +3029,7 @@ describe(MediaService.name, () => { it('should prefer higher index renderD* device for qsv', async () => { sut.videoInterfaces = { dri: ['card1', 'renderD129', 'card0', 'renderD128'], mali: false }; - mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv } }); await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -3029,7 +3049,7 @@ describe(MediaService.name, () => { }); it('should use hardware decoding for qsv if enabled', async () => { - mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv, accelDecode: true }, }); @@ -3060,7 +3080,7 @@ describe(MediaService.name, () => { }); it('should use hardware tone-mapping for qsv if hardware decoding is enabled and should tone map', async () => { - mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.videoStreamHDR }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv, accelDecode: true }, }); @@ -3093,7 +3113,7 @@ describe(MediaService.name, () => { it('should use preferred device for qsv when hardware decoding', async () => { sut.videoInterfaces = { dri: ['renderD128', 'renderD129', 'renderD130'], mali: false }; - mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv, accelDecode: true, preferredHwDevice: 'renderD129' }, }); @@ -3111,7 +3131,7 @@ describe(MediaService.name, () => { }); it('should set format to nv12 for qsv if input is not yuv420p', async () => { - mocks.media.probe.mockResolvedValue(probeStub.videoStream10Bit); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.videoStream10Bit }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv, accelDecode: true }, }); @@ -3139,7 +3159,7 @@ describe(MediaService.name, () => { }); it('should set options for vaapi', async () => { - mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi } }); await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -3182,7 +3202,7 @@ describe(MediaService.name, () => { }); it('should set vbr options for vaapi when max bitrate is enabled', async () => { - mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi, maxBitrate: '10000k' }, }); @@ -3215,7 +3235,7 @@ describe(MediaService.name, () => { }); it('should set cq options for vaapi when max bitrate is disabled', async () => { - mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi } }); await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -3246,7 +3266,7 @@ describe(MediaService.name, () => { }); it('should omit preset for vaapi if invalid', async () => { - mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi, preset: 'invalid' }, }); @@ -3269,7 +3289,7 @@ describe(MediaService.name, () => { it('should prefer higher index renderD* device for vaapi', async () => { sut.videoInterfaces = { dri: ['card1', 'renderD129', 'card0', 'renderD128'], mali: false }; - mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi } }); await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -3290,7 +3310,7 @@ describe(MediaService.name, () => { it('should select specific gpu node if selected', async () => { sut.videoInterfaces = { dri: ['renderD129', 'card1', 'card0', 'renderD128'], mali: false }; - mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi, preferredHwDevice: '/dev/dri/renderD128' }, }); @@ -3312,7 +3332,7 @@ describe(MediaService.name, () => { }); it('should use hardware decoding for vaapi if enabled', async () => { - mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi, accelDecode: true }, }); @@ -3341,7 +3361,7 @@ describe(MediaService.name, () => { }); it('should use hardware tone-mapping for vaapi if hardware decoding is enabled and should tone map', async () => { - mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.videoStreamHDR }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi, accelDecode: true }, }); @@ -3371,7 +3391,7 @@ describe(MediaService.name, () => { }); it('should set format to nv12 for vaapi if input is not yuv420p', async () => { - mocks.media.probe.mockResolvedValue(probeStub.videoStream10Bit); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.videoStream10Bit }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi, accelDecode: true }, }); @@ -3398,7 +3418,7 @@ describe(MediaService.name, () => { it('should use preferred device for vaapi when hardware decoding', async () => { sut.videoInterfaces = { dri: ['renderD128', 'renderD129', 'renderD130'], mali: false }; - mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi, accelDecode: true, preferredHwDevice: 'renderD129' }, }); @@ -3416,7 +3436,7 @@ describe(MediaService.name, () => { }); it('should fallback to hw encoding and sw decoding if hw transcoding fails and hw decoding is enabled', async () => { - mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi, accelDecode: true }, }); @@ -3440,7 +3460,7 @@ describe(MediaService.name, () => { }); it('should fallback to sw decoding if fallback to sw decoding + hw encoding fails', async () => { - mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi, accelDecode: true }, }); @@ -3460,7 +3480,7 @@ describe(MediaService.name, () => { }); it('should fallback to sw transcoding if hw transcoding fails and hw decoding is disabled', async () => { - mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi } }); mocks.media.transcode.mockRejectedValueOnce(new Error('error')); await sut.handleVideoConversion({ id: 'video-id' }); @@ -3478,14 +3498,14 @@ describe(MediaService.name, () => { it('should fail for vaapi if no hw devices', async () => { sut.videoInterfaces = { dri: [], mali: true }; - mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi } }); await expect(sut.handleVideoConversion({ id: 'video-id' })).rejects.toThrowError(); expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should set options for rkmpp', async () => { - mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Rkmpp, accelDecode: true }, }); @@ -3535,7 +3555,7 @@ describe(MediaService.name, () => { }); it('should set vbr options for rkmpp when max bitrate is enabled', async () => { - mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.videoStreamVp9 }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Rkmpp, @@ -3573,7 +3593,7 @@ describe(MediaService.name, () => { }); it('should set cqp options for rkmpp when max bitrate is disabled', async () => { - mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Rkmpp, accelDecode: true, crf: 30, maxBitrate: '0' }, }); @@ -3606,7 +3626,7 @@ describe(MediaService.name, () => { }); it('should set OpenCL tonemapping options for rkmpp when OpenCL is available', async () => { - mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.videoStreamHDR }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Rkmpp, accelDecode: true, crf: 30, maxBitrate: '0' }, }); @@ -3635,7 +3655,7 @@ describe(MediaService.name, () => { it('should set hardware decoding options for rkmpp when hardware decoding is enabled with no OpenCL on non-HDR file', async () => { sut.videoInterfaces = { dri: ['renderD128'], mali: false }; - mocks.media.probe.mockResolvedValue(probeStub.noAudioStreams); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.noAudioStreams }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Rkmpp, accelDecode: true, crf: 30, maxBitrate: '0' }, }); @@ -3661,7 +3681,7 @@ describe(MediaService.name, () => { }); it('should use software decoding and tone-mapping if hardware decoding is disabled', async () => { - mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.videoStreamHDR }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Rkmpp, accelDecode: false, crf: 30, maxBitrate: '0' }, }); @@ -3683,7 +3703,7 @@ describe(MediaService.name, () => { it('should use software tone-mapping if opencl is not available', async () => { sut.videoInterfaces = { dri: ['renderD128'], mali: false }; - mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.videoStreamHDR }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Rkmpp, accelDecode: true, crf: 30, maxBitrate: '0' }, }); @@ -3704,7 +3724,7 @@ describe(MediaService.name, () => { }); it('should tonemap when policy is required and video is hdr', async () => { - mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.videoStreamHDR }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Required } }); await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -3726,7 +3746,7 @@ describe(MediaService.name, () => { }); it('should tonemap when policy is optimal and video is hdr', async () => { - mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.videoStreamHDR }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Optimal } }); await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -3748,7 +3768,7 @@ describe(MediaService.name, () => { }); it('should transcode when policy is required and video is not yuv420p', async () => { - mocks.media.probe.mockResolvedValue(probeStub.videoStream10Bit); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.videoStream10Bit }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Required } }); await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -3763,7 +3783,7 @@ describe(MediaService.name, () => { }); it('should convert to yuv420p when scaling without tone-mapping', async () => { - mocks.media.probe.mockResolvedValue(probeStub.videoStream4K10Bit); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.videoStream4K10Bit }); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Required } }); await sut.handleVideoConversion({ id: 'video-id' }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -3778,38 +3798,30 @@ describe(MediaService.name, () => { }); it('should count frames for progress when log level is debug', async () => { - mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer }); mocks.logger.isLevelEnabled.mockReturnValue(true); await sut.handleVideoConversion({ id: 'video-id' }); - expect(mocks.media.probe).toHaveBeenCalledWith('/original/path.ext', { countFrames: true }); expect(mocks.media.transcode).toHaveBeenCalledWith('/original/path.ext', expect.any(String), { inputOptions: expect.any(Array), outputOptions: expect.any(Array), twoPass: false, progress: { - frameCount: probeStub.videoStream2160p.videoStreams[0].frameCount, + frameCount: probeStub.videoStream2160p.videoStream!.frameCount, percentInterval: expect.any(Number), }, }); }); it('should not count frames for progress when log level is not debug', async () => { - mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.videoStream2160p }); mocks.logger.isLevelEnabled.mockReturnValue(false); await sut.handleVideoConversion({ id: 'video-id' }); - - expect(mocks.media.probe).toHaveBeenCalledWith('/original/path.ext', { countFrames: false }); }); it('should process unknown audio stream', async () => { - const asset = AssetFactory.create({ - type: AssetType.Video, - originalPath: '/original/path.ext', - }); - mocks.media.probe.mockResolvedValue(probeStub.audioStreamUnknown); - mocks.asset.getByIds.mockResolvedValue([asset]); + mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.audioStreamUnknown }); await sut.handleVideoConversion({ id: asset.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 2c9325c976..0b8a1f6702 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -15,7 +15,6 @@ import { ImageFormat, JobName, JobStatus, - LogLevel, QueueName, RawExtractedFormat, StorageFolder, @@ -506,10 +505,7 @@ export class MediaService extends BaseService { }; } - private async generateVideoThumbnails( - asset: ThumbnailPathEntity & { originalPath: string }, - { ffmpeg, image }: SystemConfig, - ) { + private async generateVideoThumbnails(asset: ThumbnailAsset, { ffmpeg, image }: SystemConfig) { const previewFile = this.getImageFile(asset, { fileType: AssetFileType.Preview, format: image.preview.format, @@ -526,22 +522,15 @@ export class MediaService extends BaseService { }); this.storageCore.ensureFolders(previewFile.path); - const { format, audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath); - const mainVideoStream = this.getMainStream(videoStreams); - if (!mainVideoStream) { - throw new Error(`No video streams found for asset ${asset.id}`); + const { videoStream, format } = asset; + if (!videoStream || !format) { + throw new Error(`Missing video metadata for asset ${asset.id}`); } - const mainAudioStream = this.getMainStream(audioStreams); const previewConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.preview.size.toString() }); - const thumbnailConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.thumbnail.size.toString() }); - const previewOptions = previewConfig.getCommand(TranscodeTarget.Video, mainVideoStream, mainAudioStream, format); - const thumbnailOptions = thumbnailConfig.getCommand( - TranscodeTarget.Video, - mainVideoStream, - mainAudioStream, - format, - ); + const thumbConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.thumbnail.size.toString() }); + const previewOptions = previewConfig.getCommand(TranscodeTarget.Video, videoStream, undefined, format ?? undefined); + const thumbnailOptions = thumbConfig.getCommand(TranscodeTarget.Video, videoStream, undefined, format ?? undefined); await this.mediaRepository.transcode(asset.originalPath, previewFile.path, previewOptions); await this.mediaRepository.transcode(asset.originalPath, thumbnailFile.path, thumbnailOptions); @@ -554,7 +543,7 @@ export class MediaService extends BaseService { return { files: [previewFile, thumbnailFile], thumbhash, - fullsizeDimensions: { width: mainVideoStream.width, height: mainVideoStream.height }, + fullsizeDimensions: { width: videoStream.width, height: videoStream.height }, }; } @@ -588,17 +577,14 @@ export class MediaService extends BaseService { const output = StorageCore.getEncodedVideoPath(asset); this.storageCore.ensureFolders(output); - const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(input, { - countFrames: this.logger.isLevelEnabled(LogLevel.Debug), // makes frame count more reliable for progress logs - }); - const videoStream = this.getMainStream(videoStreams); - const audioStream = this.getMainStream(audioStreams); - if (!videoStream || !format.formatName) { + const { videoStream, format } = asset; + const audioStream = asset.audioStream ?? undefined; + if (!videoStream || !format) { + this.logger.warn(`Skipped transcoding for asset ${asset.id}: missing metadata; re-run extraction first`); return JobStatus.Failed; } - if (!videoStream.height || !videoStream.width) { - this.logger.warn(`Skipped transcoding for asset ${asset.id}: no video streams found`); + this.logger.warn(`Skipped transcoding for asset ${asset.id}: no video dimensions`); return JobStatus.Failed; } @@ -667,12 +653,6 @@ export class MediaService extends BaseService { return JobStatus.Success; } - private getMainStream(streams: T[]): T { - return streams - .filter((stream) => stream.codecName !== 'unknown') - .toSorted((stream1, stream2) => stream2.bitrate - stream1.bitrate)[0]; - } - private getTranscodeTarget( config: SystemConfigFFmpegDto, videoStream: VideoStreamInfo, diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index b796094bb5..19efd50e99 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -18,7 +18,7 @@ import { ImmichTags } from 'src/repositories/metadata.repository'; import { firstDateTime, MetadataService } from 'src/services/metadata.service'; import { AssetFactory } from 'test/factories/asset.factory'; import { PersonFactory } from 'test/factories/person.factory'; -import { probeStub } from 'test/fixtures/media.stub'; +import { videoInfoStub } from 'test/fixtures/media.stub'; import { tagStub } from 'test/fixtures/tag.stub'; import { getForMetadataExtraction, getForSidecarWrite } from 'test/mappers'; import { factory } from 'test/small.factory'; @@ -59,6 +59,15 @@ const makeFaceTags = (face: Partial<{ Name: string }> = {}, orientation?: Immich }, }); +const emptyPackets = { + totalDuration: 0, + packetCount: 0, + outputFrames: 0, + keyframePts: [], + keyframeAccDuration: [], + keyframeOwnDuration: [], +}; + describe(MetadataService.name, () => { let sut: MetadataService; let mocks: ServiceMocks; @@ -183,9 +192,12 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); - expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ dateTimeOriginal: sidecarDate }), { - lockedPropertiesBehavior: 'skip', - }); + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( + expect.objectContaining({ + exif: expect.objectContaining({ dateTimeOriginal: sidecarDate }), + lockedPropertiesBehavior: 'skip', + }), + ); expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ id: asset.id, @@ -212,8 +224,10 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( - expect.objectContaining({ dateTimeOriginal: fileModifiedAt }), - { lockedPropertiesBehavior: 'skip' }, + expect.objectContaining({ + exif: expect.objectContaining({ dateTimeOriginal: fileModifiedAt }), + lockedPropertiesBehavior: 'skip', + }), ); expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, @@ -242,8 +256,10 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( - expect.objectContaining({ dateTimeOriginal: fileCreatedAt }), - { lockedPropertiesBehavior: 'skip' }, + expect.objectContaining({ + exif: expect.objectContaining({ dateTimeOriginal: fileCreatedAt }), + lockedPropertiesBehavior: 'skip', + }), ); expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, @@ -265,9 +281,11 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ - dateTimeOriginal: new Date('2022-01-01T00:00:00.000Z'), + exif: expect.objectContaining({ + dateTimeOriginal: new Date('2022-01-01T00:00:00.000Z'), + }), + lockedPropertiesBehavior: 'skip', }), - { lockedPropertiesBehavior: 'skip' }, ); expect(mocks.asset.update).toHaveBeenCalledWith( @@ -290,9 +308,12 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); - expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ iso: 160 }), { - lockedPropertiesBehavior: 'skip', - }); + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( + expect.objectContaining({ + exif: expect.objectContaining({ iso: 160 }), + lockedPropertiesBehavior: 'skip', + }), + ); expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, duration: null, @@ -323,8 +344,10 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( - expect.objectContaining({ city: null, state: null, country: null }), - { lockedPropertiesBehavior: 'skip' }, + expect.objectContaining({ + exif: expect.objectContaining({ city: null, state: null, country: null }), + lockedPropertiesBehavior: 'skip', + }), ); expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, @@ -353,8 +376,10 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( - expect.objectContaining({ city: 'City', state: 'State', country: 'Country' }), - { lockedPropertiesBehavior: 'skip' }, + expect.objectContaining({ + exif: expect.objectContaining({ city: 'City', state: 'State', country: 'Country' }), + lockedPropertiesBehavior: 'skip', + }), ); expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, @@ -378,8 +403,10 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( - expect.objectContaining({ latitude: null, longitude: null }), - { lockedPropertiesBehavior: 'skip' }, + expect.objectContaining({ + exif: expect.objectContaining({ latitude: null, longitude: null }), + lockedPropertiesBehavior: 'skip', + }), ); }); @@ -585,7 +612,7 @@ describe(MetadataService.name, () => { it('should not apply motion photos if asset is video', async () => { const asset = AssetFactory.create({ type: AssetType.Video }); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); - mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); + mocks.media.probe.mockResolvedValue(videoInfoStub.matroskaContainer); await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); @@ -611,15 +638,144 @@ describe(MetadataService.name, () => { it('should extract the correct video orientation', async () => { const asset = AssetFactory.create({ type: AssetType.Video }); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); - mocks.media.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); + mocks.media.probe.mockResolvedValue(videoInfoStub.videoStreamVertical2160p); mockReadTags({}); await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( - expect.objectContaining({ orientation: ExifOrientation.Rotate270CW.toString() }), - { lockedPropertiesBehavior: 'skip' }, + expect.objectContaining({ + exif: expect.objectContaining({ orientation: ExifOrientation.Rotate270CW.toString() }), + lockedPropertiesBehavior: 'skip', + }), + ); + }); + + it('should persist CICP smallints and profile/level for HDR10 video', async () => { + const asset = AssetFactory.create({ type: AssetType.Video }); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); + mocks.media.probe.mockResolvedValue(videoInfoStub.videoStreamHDR10); + mocks.media.probePackets.mockResolvedValue(emptyPackets); + mockReadTags({}); + + await sut.handleMetadataExtraction({ id: asset.id }); + + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( + expect.objectContaining({ + exif: expect.objectContaining({ fps: 59.94 }), + video: expect.objectContaining({ + codecName: 'hevc', + profile: 2, + level: 153, + pixelFormat: 'yuv420p10le', + colorPrimaries: 9, + colorTransfer: 16, + colorMatrix: 9, + dvProfile: undefined, + }), + }), + ); + }); + + it('should persist Dolby Vision fields', async () => { + const asset = AssetFactory.create({ type: AssetType.Video }); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); + mocks.media.probe.mockResolvedValue(videoInfoStub.videoStreamDolbyVision); + mocks.media.probePackets.mockResolvedValue(emptyPackets); + mockReadTags({}); + + await sut.handleMetadataExtraction({ id: asset.id }); + + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( + expect.objectContaining({ + video: expect.objectContaining({ + dvProfile: 8, + dvLevel: 10, + dvBlSignalCompatibilityId: 4, + colorTransfer: 18, // ARIB_STD_B67 + }), + }), + ); + }); + + it('should persist packet-derived HLS fields', async () => { + const asset = AssetFactory.create({ type: AssetType.Video }); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); + mocks.media.probe.mockResolvedValue(videoInfoStub.videoStreamHDR10); + mocks.media.probePackets.mockResolvedValue({ + totalDuration: 12_080, + packetCount: 1148, + outputFrames: 1149, + keyframePts: [-590, 10, 611, 1211], + keyframeAccDuration: [10, 610, 6110, 12_080], + keyframeOwnDuration: [10, 10, 10, 10], + }); + mockReadTags({}); + + await sut.handleMetadataExtraction({ id: asset.id }); + + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( + expect.objectContaining({ + video: expect.objectContaining({ timeBase: 600 }), + keyframes: expect.objectContaining({ + totalDuration: 12_080, + packetCount: 1148, + outputFrames: 1149, + pts: [-590, 10, 611, 1211], + accDuration: [10, 610, 6110, 12_080], + ownDuration: [10, 10, 10, 10], + }), + }), + ); + }); + + it('should omit the keyframe row when the probe returns no keyframes', async () => { + const asset = AssetFactory.create({ type: AssetType.Video }); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); + mocks.media.probe.mockResolvedValue(videoInfoStub.videoStreamHDR10); + mocks.media.probePackets.mockResolvedValue(emptyPackets); + mockReadTags({}); + + await sut.handleMetadataExtraction({ id: asset.id }); + + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( + expect.not.objectContaining({ keyframes: expect.anything() }), + ); + }); + + it('should prefer ffprobe frameRate over exiftool VideoFrameRate', async () => { + const asset = AssetFactory.create({ type: AssetType.Video }); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); + mocks.media.probe.mockResolvedValue(videoInfoStub.videoStreamHDR10); + mocks.media.probePackets.mockResolvedValue(emptyPackets); + mockReadTags({ VideoFrameRate: '30' }); + + await sut.handleMetadataExtraction({ id: asset.id }); + + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( + expect.objectContaining({ + exif: expect.objectContaining({ fps: 59.94 }), + lockedPropertiesBehavior: 'skip', + }), + ); + }); + + it('should not insert audio/video/keyframe rows for image assets', async () => { + const asset = AssetFactory.create({ type: AssetType.Image }); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); + mockReadTags({}); + + await sut.handleMetadataExtraction({ id: asset.id }); + + expect(mocks.media.probe).not.toHaveBeenCalled(); + expect(mocks.media.probePackets).not.toHaveBeenCalled(); + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( + expect.not.objectContaining({ + audio: expect.anything(), + video: expect.anything(), + keyframes: expect.anything(), + }), ); }); @@ -909,39 +1065,41 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( - { - assetId: asset.id, - bitsPerSample: expect.any(Number), - autoStackId: null, - colorspace: tags.ColorSpace, - dateTimeOriginal: dateForTest, - description: tags.ImageDescription, - exifImageHeight: null, - exifImageWidth: null, - exposureTime: tags.ExposureTime, - fNumber: null, - fileSizeInByte: 123_456, - focalLength: tags.FocalLength, - fps: null, - iso: tags.ISO, - latitude: null, - lensModel: tags.LensModel, - livePhotoCID: tags.MediaGroupUUID, - longitude: null, - make: tags.Make, - model: tags.Model, - modifyDate: expect.any(Date), - orientation: tags.Orientation?.toString(), - profileDescription: tags.ProfileDescription, - projectionType: 'EQUIRECTANGULAR', - timeZone: tags.zone, - rating: tags.Rating, - country: null, - state: null, - city: null, - tags: ['parent/child'], - }, - { lockedPropertiesBehavior: 'skip' }, + expect.objectContaining({ + exif: { + assetId: asset.id, + bitsPerSample: expect.any(Number), + autoStackId: null, + colorspace: tags.ColorSpace, + dateTimeOriginal: dateForTest, + description: tags.ImageDescription, + exifImageHeight: null, + exifImageWidth: null, + exposureTime: tags.ExposureTime, + fNumber: null, + fileSizeInByte: 123_456, + focalLength: tags.FocalLength, + fps: null, + iso: tags.ISO, + latitude: null, + lensModel: tags.LensModel, + livePhotoCID: tags.MediaGroupUUID, + longitude: null, + make: tags.Make, + model: tags.Model, + modifyDate: expect.any(Date), + orientation: tags.Orientation?.toString(), + profileDescription: tags.ProfileDescription, + projectionType: 'EQUIRECTANGULAR', + timeZone: tags.zone, + rating: tags.Rating, + country: null, + state: null, + city: null, + tags: ['parent/child'], + }, + lockedPropertiesBehavior: 'skip', + }), ); expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ @@ -975,9 +1133,11 @@ describe(MetadataService.name, () => { expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ - timeZone: 'UTC+0', + exif: expect.objectContaining({ + timeZone: 'UTC+0', + }), + lockedPropertiesBehavior: 'skip', }), - { lockedPropertiesBehavior: 'skip' }, ); }); @@ -985,9 +1145,9 @@ describe(MetadataService.name, () => { const asset = AssetFactory.create({ type: AssetType.Video }); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.media.probe.mockResolvedValue({ - ...probeStub.videoStreamH264, + ...videoInfoStub.videoStreamH264, format: { - ...probeStub.videoStreamH264.format, + ...videoInfoStub.videoStreamH264.format, duration: 6.21, }, }); @@ -1008,9 +1168,9 @@ describe(MetadataService.name, () => { const asset = AssetFactory.create(); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.media.probe.mockResolvedValue({ - ...probeStub.videoStreamH264, + ...videoInfoStub.videoStreamH264, format: { - ...probeStub.videoStreamH264.format, + ...videoInfoStub.videoStreamH264.format, duration: 6.21, }, }); @@ -1030,9 +1190,9 @@ describe(MetadataService.name, () => { const asset = AssetFactory.create({ type: AssetType.Video }); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.media.probe.mockResolvedValue({ - ...probeStub.videoStreamH264, + ...videoInfoStub.videoStreamH264, format: { - ...probeStub.videoStreamH264.format, + ...videoInfoStub.videoStreamH264.format, duration: 0, }, }); @@ -1053,9 +1213,9 @@ describe(MetadataService.name, () => { const asset = AssetFactory.create({ type: AssetType.Video }); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.media.probe.mockResolvedValue({ - ...probeStub.videoStreamH264, + ...videoInfoStub.videoStreamH264, format: { - ...probeStub.videoStreamH264.format, + ...videoInfoStub.videoStreamH264.format, duration: 604_800, }, }); @@ -1111,9 +1271,9 @@ describe(MetadataService.name, () => { mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ Duration: 123 }, {}); mocks.media.probe.mockResolvedValue({ - ...probeStub.videoStreamH264, + ...videoInfoStub.videoStreamH264, format: { - ...probeStub.videoStreamH264.format, + ...videoInfoStub.videoStreamH264.format, duration: 456, }, }); @@ -1132,18 +1292,22 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ - description: '', + exif: expect.objectContaining({ + description: '', + }), + lockedPropertiesBehavior: 'skip', }), - { lockedPropertiesBehavior: 'skip' }, ); mockReadTags({ ImageDescription: ' my\n description' }); await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ - description: 'my\n description', + exif: expect.objectContaining({ + description: 'my\n description', + }), + lockedPropertiesBehavior: 'skip', }), - { lockedPropertiesBehavior: 'skip' }, ); }); @@ -1155,9 +1319,11 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ - description: '1000', + exif: expect.objectContaining({ + description: '1000', + }), + lockedPropertiesBehavior: 'skip', }), - { lockedPropertiesBehavior: 'skip' }, ); }); @@ -1388,9 +1554,11 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ - modifyDate: expect.any(Date), + exif: expect.objectContaining({ + modifyDate: expect.any(Date), + }), + lockedPropertiesBehavior: 'skip', }), - { lockedPropertiesBehavior: 'skip' }, ); }); @@ -1402,9 +1570,11 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ - rating: null, + exif: expect.objectContaining({ + rating: null, + }), + lockedPropertiesBehavior: 'skip', }), - { lockedPropertiesBehavior: 'skip' }, ); }); @@ -1416,9 +1586,11 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ - rating: 5, + exif: expect.objectContaining({ + rating: 5, + }), + lockedPropertiesBehavior: 'skip', }), - { lockedPropertiesBehavior: 'skip' }, ); }); @@ -1430,9 +1602,11 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ - rating: null, + exif: expect.objectContaining({ + rating: null, + }), + lockedPropertiesBehavior: 'skip', }), - { lockedPropertiesBehavior: 'skip' }, ); }); @@ -1444,9 +1618,11 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ - rating: -1, + exif: expect.objectContaining({ + rating: -1, + }), + lockedPropertiesBehavior: 'skip', }), - { lockedPropertiesBehavior: 'skip' }, ); }); @@ -1466,7 +1642,7 @@ describe(MetadataService.name, () => { it('should handle not finding a match', async () => { const asset = AssetFactory.create({ type: AssetType.Video }); - mocks.media.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); + mocks.media.probe.mockResolvedValue(videoInfoStub.videoStreamVertical2160p); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mockReadTags({ ContentIdentifier: 'CID' }); @@ -1578,9 +1754,12 @@ describe(MetadataService.name, () => { mockReadTags(exif); await sut.handleMetadataExtraction({ id: asset.id }); - expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining(expected), { - lockedPropertiesBehavior: 'skip', - }); + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( + expect.objectContaining({ + exif: expect.objectContaining(expected), + lockedPropertiesBehavior: 'skip', + }), + ); }); it.each([ @@ -1605,9 +1784,11 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ - lensModel: expected, + exif: expect.objectContaining({ + lensModel: expected, + }), + lockedPropertiesBehavior: 'skip', }), - { lockedPropertiesBehavior: 'skip' }, ); }); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 168f7634fd..a3e9c19472 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -243,10 +243,11 @@ export class MetadataService extends BaseService { return; } - const [exifTags, stats] = await Promise.all([ + const [exifResult, stats] = await Promise.all([ this.getExifTags(asset), this.storageRepository.stat(asset.originalPath), ]); + const { tags: exifTags, audio, video, packets, format } = exifResult; this.logger.verbose('Exif Tags', exifTags); const dates = this.getDates(asset, exifTags, stats); @@ -294,7 +295,7 @@ export class MetadataService extends BaseService { exifTags.Make ?? exifTags.Device?.Manufacturer ?? exifTags.AndroidMake ?? (exifTags.DeviceManufacturer || null), model: exifTags.Model ?? exifTags.Device?.ModelName ?? exifTags.AndroidModel ?? (exifTags.DeviceModelName || null), - fps: validate(Number.parseFloat(exifTags.VideoFrameRate!)), + fps: video?.frameRate ?? validate(Number.parseFloat(exifTags.VideoFrameRate!)), iso: validate(exifTags.ISO) as number, exposureTime: exifTags.ExposureTime ?? null, lensModel: getLensModel(exifTags), @@ -313,6 +314,53 @@ export class MetadataService extends BaseService { tags: tags.length > 0 ? tags : null, }; + const audioData = + format && audio?.codecName + ? { + assetId: asset.id, + bitrate: audio.bitrate, + index: audio.index, + profile: audio.profile, + codecName: audio.codecName, + } + : undefined; + + const videoData = + format?.formatName && format?.formatLongName && video?.codecName && video?.timeBase + ? { + assetId: asset.id, + bitrate: video.bitrate, + frameCount: video.frameCount, + timeBase: video.timeBase, + index: video.index, + profile: video.profile, + level: video.level, + colorPrimaries: video.colorPrimaries, + colorTransfer: video.colorTransfer, + colorMatrix: video.colorMatrix, + dvProfile: video.dvProfile, + dvLevel: video.dvLevel, + dvBlSignalCompatibilityId: video.dvBlSignalCompatibilityId, + codecName: video.codecName, + formatName: format.formatName, + formatLongName: format.formatLongName, + pixelFormat: video.pixelFormat, + } + : undefined; + + const keyframeData = + packets && packets.keyframePts.length > 0 + ? { + assetId: asset.id, + totalDuration: packets.totalDuration, + packetCount: packets.packetCount, + outputFrames: packets.outputFrames, + pts: packets.keyframePts, + accDuration: packets.keyframeAccDuration, + ownDuration: packets.keyframeOwnDuration, + } + : undefined; + const isSidewards = exifTags.Orientation && this.isOrientationSidewards(exifTags.Orientation); const assetWidth = isSidewards ? validate(height) : validate(width); const assetHeight = isSidewards ? validate(width) : validate(height); @@ -333,7 +381,13 @@ export class MetadataService extends BaseService { height: !asset.isEdited || asset.height == null ? assetHeight : undefined, }), async () => { - await this.assetRepository.upsertExif(exifData, { lockedPropertiesBehavior: 'skip' }); + await this.assetRepository.upsertExif({ + exif: exifData, + audio: audioData, + video: videoData, + keyframes: keyframeData, + lockedPropertiesBehavior: 'skip', + }); await this.applyTagList(asset); }, ); @@ -523,13 +577,14 @@ export class MetadataService extends BaseService { return { width, height }; } - private async getExifTags(asset: { originalPath: string; files: AssetFile[]; type: AssetType }): Promise { + private async getExifTags(asset: { originalPath: string; files: AssetFile[]; type: AssetType }) { const { sidecarFile } = getAssetFiles(asset.files); + const shouldProbe = asset.type === AssetType.Video || asset.originalPath.toLowerCase().endsWith('.gif'); - const [mediaTags, sidecarTags, videoTags] = await Promise.all([ + const [mediaTags, sidecarTags, videoResult] = await Promise.all([ this.metadataRepository.readTags(asset.originalPath), sidecarFile ? this.metadataRepository.readTags(sidecarFile.path) : null, - asset.type === AssetType.Video ? this.getVideoTags(asset.originalPath) : null, + shouldProbe ? this.getVideoTags(asset.originalPath) : null, ]); // prefer dates from sidecar tags @@ -554,14 +609,20 @@ export class MetadataService extends BaseService { // prefer duration from video tags // don't save duration if asset is definitely not an animated image (see e.g. CR3 with Duration: 1s) - if (videoTags || !mimeTypes.isPossiblyAnimatedImage(asset.originalPath)) { + if (videoResult || !mimeTypes.isPossiblyAnimatedImage(asset.originalPath)) { delete mediaTags.Duration; } // never use duration from sidecar delete sidecarTags?.Duration; - return { ...mediaTags, ...videoTags, ...sidecarTags }; + return { + tags: { ...mediaTags, ...videoResult?.tags, ...sidecarTags }, + audio: videoResult?.audio, + video: videoResult?.video, + packets: videoResult?.packets, + format: videoResult?.format ?? null, + }; } private getTagList(exifTags: ImmichTags): string[] { @@ -1008,20 +1069,22 @@ export class MetadataService extends BaseService { } private async getVideoTags(originalPath: string) { - const { videoStreams, format } = await this.mediaRepository.probe(originalPath); + const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(originalPath); + const video = videoStreams[0]; + const audio = audioStreams[0]; + const packets = video?.timeBase ? await this.mediaRepository.probePackets(originalPath, video.index) : null; const tags: Pick = {}; - if (videoStreams[0]) { - // Set video dimensions - if (videoStreams[0].width) { - tags.ImageWidth = videoStreams[0].width; + if (video) { + if (video.width) { + tags.ImageWidth = video.width; } - if (videoStreams[0].height) { - tags.ImageHeight = videoStreams[0].height; + if (video.height) { + tags.ImageHeight = video.height; } - switch (videoStreams[0].rotation) { + switch (video.rotation) { case -90: { tags.Orientation = ExifOrientation.Rotate90CW; break; @@ -1045,6 +1108,6 @@ export class MetadataService extends BaseService { tags.Duration = format.duration; } - return tags; + return { tags, audio, video, packets, format }; } } diff --git a/server/src/services/shared-link.service.ts b/server/src/services/shared-link.service.ts index 31c50b7c2c..0643a432b8 100644 --- a/server/src/services/shared-link.service.ts +++ b/server/src/services/shared-link.service.ts @@ -110,7 +110,8 @@ export class SharedLinkService extends BaseService { private handleError(error: unknown): never { if ((error as PostgresError).constraint_name === 'shared_link_slug_uq') { - throw new BadRequestException('Shared link with this slug already exists'); + this.logger.debug('Shared link with this slug already exists'); + throw new BadRequestException('Failed to save shared link'); } throw error; } diff --git a/server/src/services/tag.service.spec.ts b/server/src/services/tag.service.spec.ts index 6fc472bb87..0c748fded8 100644 --- a/server/src/services/tag.service.spec.ts +++ b/server/src/services/tag.service.spec.ts @@ -206,16 +206,22 @@ describe(TagService.name, () => { count: 6, }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( - { assetId: 'asset-1', lockedProperties: ['tags'], tags: ['tag-1', 'tag-2'] }, - { lockedPropertiesBehavior: 'append' }, + expect.objectContaining({ + exif: { assetId: 'asset-1', lockedProperties: ['tags'], tags: ['tag-1', 'tag-2'] }, + lockedPropertiesBehavior: 'append', + }), ); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( - { assetId: 'asset-2', lockedProperties: ['tags'], tags: ['tag-1', 'tag-2'] }, - { lockedPropertiesBehavior: 'append' }, + expect.objectContaining({ + exif: { assetId: 'asset-2', lockedProperties: ['tags'], tags: ['tag-1', 'tag-2'] }, + lockedPropertiesBehavior: 'append', + }), ); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( - { assetId: 'asset-3', lockedProperties: ['tags'], tags: ['tag-1', 'tag-2'] }, - { lockedPropertiesBehavior: 'append' }, + expect.objectContaining({ + exif: { assetId: 'asset-3', lockedProperties: ['tags'], tags: ['tag-1', 'tag-2'] }, + lockedPropertiesBehavior: 'append', + }), ); expect(mocks.tag.upsertAssetIds).toHaveBeenCalledWith([ { tagId: 'tag-1', assetId: 'asset-1' }, @@ -255,12 +261,16 @@ describe(TagService.name, () => { ]); expect(mocks.asset.upsertExif).not.toHaveBeenCalledWith( - { assetId: 'asset-1', lockedProperties: ['tags'], tags: ['tag-1'] }, - { lockedPropertiesBehavior: 'append' }, + expect.objectContaining({ + exif: { assetId: 'asset-1', lockedProperties: ['tags'], tags: ['tag-1'] }, + lockedPropertiesBehavior: 'append', + }), ); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( - { assetId: 'asset-2', lockedProperties: ['tags'], tags: ['tag-1'] }, - { lockedPropertiesBehavior: 'append' }, + expect.objectContaining({ + exif: { assetId: 'asset-2', lockedProperties: ['tags'], tags: ['tag-1'] }, + lockedPropertiesBehavior: 'append', + }), ); expect(mocks.tag.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']); expect(mocks.tag.addAssetIds).toHaveBeenCalledWith('tag-1', ['asset-2']); diff --git a/server/src/services/tag.service.ts b/server/src/services/tag.service.ts index d34cd84ecd..8b92d3abf8 100644 --- a/server/src/services/tag.service.ts +++ b/server/src/services/tag.service.ts @@ -152,7 +152,8 @@ export class TagService extends BaseService { private async updateTags(assetId: string) { const { tags } = await this.assetRepository.getForUpdateTags(assetId); - await this.assetRepository.upsertExif(updateLockedColumns({ assetId, tags: tags.map(({ value }) => value) }), { + await this.assetRepository.upsertExif({ + exif: updateLockedColumns({ assetId, tags: tags.map(({ value }) => value) }), lockedPropertiesBehavior: 'append', }); } diff --git a/server/src/services/user-admin.service.ts b/server/src/services/user-admin.service.ts index 58b4221cc9..2a57fdd299 100644 --- a/server/src/services/user-admin.service.ts +++ b/server/src/services/user-admin.service.ts @@ -64,7 +64,8 @@ export class UserAdminService extends BaseService { if (dto.email) { const duplicate = await this.userRepository.getByEmail(dto.email); if (duplicate && duplicate.id !== id) { - throw new BadRequestException('Email already in use by another account'); + this.logger.debug('Email already in use by another account'); + throw new BadRequestException('Email is not available'); } } diff --git a/server/src/services/user.service.spec.ts b/server/src/services/user.service.spec.ts index 847f96cfc6..a00efe82fd 100644 --- a/server/src/services/user.service.spec.ts +++ b/server/src/services/user.service.spec.ts @@ -179,7 +179,7 @@ describe(UserService.name, () => { it('should throw an error if the user does not exist', async () => { mocks.user.get.mockResolvedValue(void 0); - await expect(sut.getProfileImage(userStub.admin.id)).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.getProfileImage(userStub.admin.id)).rejects.toBeInstanceOf(NotFoundException); expect(mocks.user.get).toHaveBeenCalledWith(userStub.admin.id, {}); }); diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index 8e1f74bcf4..82ab90a590 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -49,7 +49,8 @@ export class UserService extends BaseService { if (dto.email) { const duplicate = await this.userRepository.getByEmail(dto.email); if (duplicate && duplicate.id !== user.id) { - throw new BadRequestException('Email already in use by another account'); + this.logger.warn('Email already in use by another account'); + throw new BadRequestException('Email is not available'); } } @@ -134,9 +135,10 @@ export class UserService extends BaseService { } async getProfileImage(id: string): Promise { - const user = await this.findOrFail(id, {}); - if (!user.profileImagePath) { - throw new NotFoundException('User does not have a profile image'); + const user = await this.userRepository.get(id, {}); + if (!user || !user.profileImagePath) { + this.logger.debug('User or profile image not found'); + throw new NotFoundException(); } return new ImmichFileResponse({ diff --git a/server/src/types.ts b/server/src/types.ts index c33b5a18ad..86ba0a1cc2 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -7,9 +7,18 @@ import { AuthDto } from 'src/dtos/auth.dto'; import { AssetEditActionItem } from 'src/dtos/editing.dto'; import { SetMaintenanceModeDto } from 'src/dtos/maintenance.dto'; import { + AacProfile, AssetOrder, AssetType, + Av1Profile, + ColorMatrix, + ColorPrimaries, + ColorTransfer, + DvProfile, + DvSignalCompatibility, ExifOrientation, + H264Profile, + HevcProfile, ImageFormat, JobName, MemoryType, @@ -81,21 +90,44 @@ export interface VideoStreamInfo { width: number; rotation: number; codecName?: string; + profile?: H264Profile | HevcProfile | Av1Profile; + level?: number; frameCount: number; - isHDR: boolean; + frameRate?: number; + timeBase?: number; bitrate: number; pixelFormat: string; - colorPrimaries?: string; - colorSpace?: string; - colorTransfer?: string; + colorPrimaries: ColorPrimaries; + colorMatrix: ColorMatrix; + colorTransfer: ColorTransfer; + dvProfile?: DvProfile; + dvLevel?: number; + dvBlSignalCompatibilityId?: DvSignalCompatibility; } export interface AudioStreamInfo { index: number; codecName?: string; + profile?: AacProfile; bitrate: number; } +/** Packet-derived video data needed for accurate HLS playlists. */ +export interface VideoPacketInfo { + /** Sum of source packet duration across all packets (includes discard). */ + totalDuration: number; + /** Post-discard packet count. */ + packetCount: number; + /** Output CFR frame count at `packetCount / format.duration`. */ + outputFrames: number; + /** All keyframe PTS in source ticks, including pre-roll discard keyframes. */ + keyframePts: number[]; + /** Cumulative packet duration through each keyframe, inclusive. */ + keyframeAccDuration: number[]; + /** Each keyframe's own packet duration (needed for VFR). */ + keyframeOwnDuration: number[]; +} + export interface VideoFormat { formatName?: string; formatLongName?: string; @@ -144,7 +176,7 @@ export interface VideoCodecSWConfig { getCommand( target: TranscodeTarget, videoStream: VideoStreamInfo, - audioStream: AudioStreamInfo, + audioStream?: AudioStreamInfo, format?: VideoFormat, ): TranscodeCommand; } diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 837aa9c8d5..fdf1d98caf 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -17,11 +17,11 @@ import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; import { Notice, PostgresError } from 'postgres'; import { columns, lockableProperties, LockableProperty, Person } from 'src/database'; import { AssetEditActionItem } from 'src/dtos/editing.dto'; -import { AssetFileType, AssetVisibility, DatabaseExtension } from 'src/enum'; +import { AssetFileType, AssetVisibility, DatabaseExtension, ExifOrientation } from 'src/enum'; import { AssetSearchBuilderOptions } from 'src/repositories/search.repository'; import { DB } from 'src/schema'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; -import { VectorExtension } from 'src/types'; +import { AudioStreamInfo, VectorExtension, VideoFormat, VideoStreamInfo } from 'src/types'; export const getKyselyConfig = (connection: DatabaseConnectionParams): KyselyConfig => { return { @@ -99,6 +99,65 @@ export function withExifInner(qb: SelectQueryBuilder) { .$narrowType<{ exifInfo: NotNull }>(); } +export const dummy = sql`(select 1)`.as('dummy'); + +export function withAudioStream(eb: ExpressionBuilder) { + return jsonObjectFrom( + eb + .selectFrom(dummy) + .select(['asset_audio.index', 'asset_audio.codecName', 'asset_audio.profile', 'asset_audio.bitrate']) + .where('asset_audio.assetId', 'is not', sql.lit(null)) + .$castTo(), + ); +} + +export function withVideoStream(eb: ExpressionBuilder) { + return jsonObjectFrom( + eb + .selectFrom(dummy) + .select((eb) => [ + 'asset_video.index', + 'asset_video.codecName', + 'asset_video.profile', + 'asset_video.level', + 'asset_video.bitrate', + 'asset_exif.exifImageWidth as width', + 'asset_exif.exifImageHeight as height', + 'asset_video.pixelFormat', + 'asset_video.frameCount', + 'asset_exif.fps as frameRate', + 'asset_video.timeBase', + eb + .case() + .when('asset_exif.orientation', '=', sql.lit(ExifOrientation.Rotate90CW.toString())) + .then(sql.lit(-90)) + .when('asset_exif.orientation', '=', sql.lit(ExifOrientation.Rotate270CW.toString())) + .then(sql.lit(90)) + .when('asset_exif.orientation', '=', sql.lit(ExifOrientation.Rotate180.toString())) + .then(sql.lit(180)) + .else(0) + .end() + .as('rotation'), + 'asset_video.colorPrimaries', + 'asset_video.colorMatrix', + 'asset_video.colorTransfer', + 'asset_video.dvProfile', + 'asset_video.dvLevel', + 'asset_video.dvBlSignalCompatibilityId', + ]) + .where('asset_video.assetId', 'is not', sql.lit(null)), + ).$castTo<(VideoStreamInfo & { timeBase: NotNull }) | null>(); +} + +export function withVideoFormat(eb: ExpressionBuilder) { + return jsonObjectFrom( + eb + .selectFrom(dummy) + .select(['asset_video.formatName', 'asset_video.formatLongName', 'asset.duration', 'asset_video.bitrate']) + .where('asset_video.assetId', 'is not', sql.lit(null)), + ).$castTo(); +} + export function withSmartSearch(qb: SelectQueryBuilder) { return qb .leftJoin('smart_search', 'asset.id', 'smart_search.assetId') @@ -445,5 +504,3 @@ export const updateLockedColumns = & { locked exif.lockedProperties = lockableProperties.filter((property) => property in exif); return exif; }; - -export const dummy = sql`(select 1)`.as('dummy'); diff --git a/server/src/utils/media.ts b/server/src/utils/media.ts index fb27223d3a..49e11edab7 100644 --- a/server/src/utils/media.ts +++ b/server/src/utils/media.ts @@ -1,6 +1,15 @@ import { AUDIO_ENCODER } from 'src/constants'; import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; -import { CQMode, ToneMapping, TranscodeHardwareAcceleration, TranscodeTarget, VideoCodec } from 'src/enum'; +import { + ColorMatrix, + ColorPrimaries, + ColorTransfer, + CQMode, + ToneMapping, + TranscodeHardwareAcceleration, + TranscodeTarget, + VideoCodec, +} from 'src/enum'; import { AudioStreamInfo, BitrateDistribution, @@ -255,7 +264,10 @@ export class BaseConfig implements VideoCodecSWConfig { } shouldToneMap(videoStream: VideoStreamInfo) { - return videoStream.isHDR && this.config.tonemap !== ToneMapping.Disabled; + return ( + this.config.tonemap !== ToneMapping.Disabled && + (videoStream.colorTransfer === ColorTransfer.Smpte2084 || videoStream.colorTransfer === ColorTransfer.AribStdB67) + ); } getScaling(videoStream: VideoStreamInfo, mult = 2) { @@ -409,21 +421,21 @@ export class ThumbnailConfig extends BaseConfig { : ['-skip_frame', 'nointra', '-sws_flags', 'accurate_rnd+full_chroma_int']; const metadataOverrides = []; - if (videoStream.colorPrimaries === 'reserved') { + if (videoStream.colorPrimaries === ColorPrimaries.Reserved) { metadataOverrides.push('colour_primaries=1'); } - if (videoStream.colorSpace === 'reserved') { + if (videoStream.colorMatrix === ColorMatrix.Reserved) { metadataOverrides.push('matrix_coefficients=1'); } - if (videoStream.colorTransfer === 'reserved') { + if (videoStream.colorTransfer === ColorTransfer.Reserved) { metadataOverrides.push('transfer_characteristics=1'); } if (metadataOverrides.length > 0) { // workaround for https://fftrac-bg.ffmpeg.org/ticket/11020 - options.push('-bsf:v', `${videoStream.codecName}_metadata=${metadataOverrides.join(':')}`); + options.push(`-bsf:${videoStream.index}`, `${videoStream.codecName}_metadata=${metadataOverrides.join(':')}`); } return options; diff --git a/server/test/fixtures/media.stub.ts b/server/test/fixtures/media.stub.ts index 23617fcaf0..120fe07664 100644 --- a/server/test/fixtures/media.stub.ts +++ b/server/test/fixtures/media.stub.ts @@ -1,3 +1,5 @@ +import { NotNull } from 'kysely'; +import { ColorMatrix, ColorPrimaries, ColorTransfer, DvProfile, DvSignalCompatibility } from 'src/enum'; import { AudioStreamInfo, VideoFormat, VideoInfo, VideoStreamInfo } from 'src/types'; const probeStubDefaultFormat: VideoFormat = { @@ -15,9 +17,12 @@ const probeStubDefaultVideoStream: VideoStreamInfo[] = [ codecName: 'hevc', frameCount: 100, rotation: 0, - isHDR: false, bitrate: 0, + colorPrimaries: ColorPrimaries.Bt709, + colorTransfer: ColorTransfer.Bt709, + colorMatrix: ColorMatrix.Bt709, pixelFormat: 'yuv420p', + timeBase: 600, }, ]; @@ -29,23 +34,13 @@ const probeStubDefault: VideoInfo = { audioStreams: probeStubDefaultAudioStream, }; -export const probeStub = { +/** Fixtures in the shape `mediaRepository.probe()` returns (arrays of streams, raw ffprobe format). */ +export const videoInfoStub = { noVideoStreams: Object.freeze({ ...probeStubDefault, videoStreams: [] }), noAudioStreams: Object.freeze({ ...probeStubDefault, audioStreams: [] }), multipleVideoStreams: Object.freeze({ ...probeStubDefault, videoStreams: [ - { - index: 0, - height: 1080, - width: 400, - codecName: 'hevc', - frameCount: 1, - rotation: 0, - isHDR: false, - bitrate: 100, - pixelFormat: 'yuv420p', - }, { index: 1, height: 1080, @@ -53,9 +48,26 @@ export const probeStub = { codecName: 'hevc', frameCount: 2, rotation: 0, - isHDR: false, bitrate: 101, + colorPrimaries: ColorPrimaries.Bt709, + colorTransfer: ColorTransfer.Bt709, + colorMatrix: ColorMatrix.Bt709, pixelFormat: 'yuv420p', + timeBase: 600, + }, + { + index: 0, + height: 1080, + width: 400, + codecName: 'hevc', + frameCount: 1, + rotation: 0, + bitrate: 100, + colorPrimaries: ColorPrimaries.Bt709, + colorTransfer: ColorTransfer.Bt709, + colorMatrix: ColorMatrix.Bt709, + pixelFormat: 'yuv420p', + timeBase: 600, }, { index: 2, @@ -64,18 +76,21 @@ export const probeStub = { codecName: 'h7000', frameCount: 3, rotation: 0, - isHDR: false, bitrate: 99, + colorPrimaries: ColorPrimaries.Bt709, + colorTransfer: ColorTransfer.Bt709, + colorMatrix: ColorMatrix.Bt709, pixelFormat: 'yuv420p', + timeBase: 600, }, ], }), multipleAudioStreams: Object.freeze({ ...probeStubDefault, audioStreams: [ - { index: 0, codecName: 'mp3', bitrate: 100 }, - { index: 1, codecName: 'mp3', bitrate: 101 }, { index: 2, codecName: 'mp3', bitrate: 102 }, + { index: 1, codecName: 'mp3', bitrate: 101 }, + { index: 0, codecName: 'mp3', bitrate: 100 }, ], }), noHeight: Object.freeze({ @@ -88,9 +103,12 @@ export const probeStub = { codecName: 'hevc', frameCount: 100, rotation: 0, - isHDR: false, bitrate: 0, + colorPrimaries: ColorPrimaries.Bt709, + colorTransfer: ColorTransfer.Bt709, + colorMatrix: ColorMatrix.Bt709, pixelFormat: 'yuv420p', + timeBase: 600, }, ], }), @@ -104,9 +122,12 @@ export const probeStub = { codecName: 'h264', frameCount: 100, rotation: 0, - isHDR: false, bitrate: 0, + colorPrimaries: ColorPrimaries.Bt709, + colorTransfer: ColorTransfer.Bt709, + colorMatrix: ColorMatrix.Bt709, pixelFormat: 'yuv420p', + timeBase: 600, }, ], }), @@ -117,8 +138,10 @@ export const probeStub = { videoStreamMTS: Object.freeze({ ...probeStubDefault, format: { - ...probeStubDefaultFormat, formatName: 'mpegts', + formatLongName: 'MPEG-TS (MPEG-2 Transport Stream)', + duration: 0, + bitrate: 0, }, }), videoStreamHDR: Object.freeze({ @@ -131,9 +154,12 @@ export const probeStub = { codecName: 'h264', frameCount: 100, rotation: 0, - isHDR: true, + colorPrimaries: ColorPrimaries.Bt2020, + colorMatrix: ColorMatrix.Bt2020Nc, + colorTransfer: ColorTransfer.Smpte2084, bitrate: 0, pixelFormat: 'yuv420p10le', + timeBase: 600, }, ], }), @@ -147,9 +173,12 @@ export const probeStub = { codecName: 'h264', frameCount: 100, rotation: 0, - isHDR: false, bitrate: 0, + colorPrimaries: ColorPrimaries.Bt709, + colorTransfer: ColorTransfer.Bt709, + colorMatrix: ColorMatrix.Bt709, pixelFormat: 'yuv420p10le', + timeBase: 600, }, ], }), @@ -163,9 +192,12 @@ export const probeStub = { codecName: 'h264', frameCount: 100, rotation: 0, - isHDR: false, bitrate: 0, + colorPrimaries: ColorPrimaries.Bt709, + colorTransfer: ColorTransfer.Bt709, + colorMatrix: ColorMatrix.Bt709, pixelFormat: 'yuv420p10le', + timeBase: 600, }, ], }), @@ -179,9 +211,12 @@ export const probeStub = { codecName: 'h264', frameCount: 100, rotation: 90, - isHDR: false, bitrate: 0, + colorPrimaries: ColorPrimaries.Bt709, + colorTransfer: ColorTransfer.Bt709, + colorMatrix: ColorMatrix.Bt709, pixelFormat: 'yuv420p', + timeBase: 600, }, ], }), @@ -195,9 +230,12 @@ export const probeStub = { codecName: 'h264', frameCount: 100, rotation: 0, - isHDR: false, bitrate: 0, + colorPrimaries: ColorPrimaries.Bt709, + colorTransfer: ColorTransfer.Bt709, + colorMatrix: ColorMatrix.Bt709, pixelFormat: 'yuv420p', + timeBase: 600, }, ], }), @@ -211,9 +249,12 @@ export const probeStub = { codecName: 'h264', frameCount: 100, rotation: 0, - isHDR: false, bitrate: 0, + colorPrimaries: ColorPrimaries.Bt709, + colorTransfer: ColorTransfer.Bt709, + colorMatrix: ColorMatrix.Bt709, pixelFormat: 'yuv420p', + timeBase: 600, }, ], }), @@ -274,10 +315,95 @@ export const probeStub = { videoStreams: [ { ...probeStubDefaultVideoStream[0], - colorPrimaries: 'reserved', - colorSpace: 'reserved', - colorTransfer: 'reserved', + colorPrimaries: ColorPrimaries.Reserved, + colorMatrix: ColorMatrix.Reserved, + colorTransfer: ColorTransfer.Reserved, + }, + ], + }), + videoStreamHDR10: Object.freeze({ + ...probeStubDefault, + videoStreams: [ + { + index: 0, + height: 2160, + width: 3840, + codecName: 'hevc', + profile: 2, + level: 153, + frameCount: 1208, + frameRate: 59.94, + rotation: 0, + bitrate: 64_000_000, + pixelFormat: 'yuv420p10le', + colorPrimaries: ColorPrimaries.Bt2020, + colorMatrix: ColorMatrix.Bt2020Nc, + colorTransfer: ColorTransfer.Smpte2084, + timeBase: 600, + }, + ], + }), + videoStreamDolbyVision: Object.freeze({ + ...probeStubDefault, + videoStreams: [ + { + index: 0, + height: 2160, + width: 3840, + codecName: 'hevc', + profile: 2, + level: 153, + frameCount: 1299, + frameRate: 59.94, + rotation: 0, + bitrate: 53_500_000, + pixelFormat: 'yuv420p10le', + colorPrimaries: ColorPrimaries.Bt2020, + colorMatrix: ColorMatrix.Bt2020Nc, + colorTransfer: ColorTransfer.AribStdB67, + dvProfile: DvProfile.Dvhe08, + dvLevel: 10, + dvBlSignalCompatibilityId: DvSignalCompatibility.Hlg, + timeBase: 600, + }, + ], + }), + videoStreamWithProfileLevel: Object.freeze({ + ...probeStubDefault, + videoStreams: [ + { + ...probeStubDefaultVideoStream[0], + codecName: 'h264', + profile: 100, + level: 40, + }, + ], + }), + audioStreamAAC: Object.freeze({ + ...probeStubDefault, + audioStreams: [ + { + index: 1, + codecName: 'aac', + profile: 2, + bitrate: 128_000, }, ], }), }; + +interface SelectedStreams { + videoStream: VideoStreamInfo & { timeBase: NotNull }; + audioStream: AudioStreamInfo | null; + format: VideoFormat; +} + +const toSelectedStreams = (info: VideoInfo) => ({ + videoStream: info.videoStreams[0] ?? null, + audioStream: info.audioStreams[0] ?? null, + format: info.format, +}); + +export const probeStub = Object.fromEntries( + Object.entries(videoInfoStub).map(([key, info]) => [key, toSelectedStreams(info)]), +) as Record; diff --git a/server/test/mappers.ts b/server/test/mappers.ts index 8cac6ce8fd..53d1d00a13 100644 --- a/server/test/mappers.ts +++ b/server/test/mappers.ts @@ -1,9 +1,10 @@ -import { Selectable, ShallowDehydrateObject } from 'kysely'; +import { NotNull, Selectable, ShallowDehydrateObject } from 'kysely'; import { MapAsset } from 'src/dtos/asset-response.dto'; import { AssetEditActionItem } from 'src/dtos/editing.dto'; import { ActivityTable } from 'src/schema/tables/activity.table'; import { AssetTable } from 'src/schema/tables/asset.table'; import { PartnerTable } from 'src/schema/tables/partner.table'; +import { AudioStreamInfo, VideoFormat, VideoStreamInfo } from 'src/types'; import { AlbumFactory } from 'test/factories/album.factory'; import { AssetFaceFactory } from 'test/factories/asset-face.factory'; import { AssetFactory } from 'test/factories/asset.factory'; @@ -155,6 +156,9 @@ export const getForGenerateThumbnail = (asset: ReturnType files: asset.files.map((file) => getDehydrated(file)), exifInfo: getDehydrated(asset.exifInfo), edits: asset.edits.map(({ action, parameters }) => ({ action, parameters })) as AssetEditActionItem[], + videoStream: null as (VideoStreamInfo & { timeBase: NotNull }) | null, + audioStream: null as AudioStreamInfo | null, + format: null as VideoFormat | null, }); export const getForAssetFace = (face: ReturnType) => ({ diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index c2019029da..8e3372011a 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -35,6 +35,7 @@ import { JobRepository } from 'src/repositories/job.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { MachineLearningRepository } from 'src/repositories/machine-learning.repository'; import { MapRepository } from 'src/repositories/map.repository'; +import { MediaRepository } from 'src/repositories/media.repository'; import { MemoryRepository } from 'src/repositories/memory.repository'; import { MetadataRepository } from 'src/repositories/metadata.repository'; import { NotificationRepository } from 'src/repositories/notification.repository'; @@ -218,7 +219,7 @@ export class MediumTestContext { } async newExif(dto: Insertable) { - const result = await this.get(AssetRepository).upsertExif(dto, { lockedPropertiesBehavior: 'override' }); + const result = await this.get(AssetRepository).upsertExif({ exif: dto, lockedPropertiesBehavior: 'override' }); return { result }; } @@ -362,7 +363,14 @@ export class ExifTestContext extends MediumTestContext { constructor(database: Kysely) { super(MetadataService, { database, - real: [AssetRepository, AssetJobRepository, MetadataRepository, SystemMetadataRepository, TagRepository], + real: [ + AssetRepository, + AssetJobRepository, + MediaRepository, + MetadataRepository, + SystemMetadataRepository, + TagRepository, + ], mock: [ConfigRepository, EventRepository, LoggingRepository, MapRepository, StorageRepository], }); @@ -445,6 +453,7 @@ const newRealRepository = (key: ClassConstructor, db: Kysely): T => { return new key(LoggingRepository.create()); } + case MediaRepository: case MetadataRepository: { return new key(LoggingRepository.create()); } diff --git a/server/test/medium/responses.ts b/server/test/medium/responses.ts index 2fcab5b2dc..b416b3b904 100644 --- a/server/test/medium/responses.ts +++ b/server/test/medium/responses.ts @@ -25,6 +25,10 @@ export const errorDto = { badRequest: (message: any = null) => ({ message: message ?? expect.anything(), }), + validationError: (errors?: ReadonlyArray<{ path: ReadonlyArray; message: string }>) => ({ + message: 'Validation failed', + errors: errors ? expect.arrayContaining(errors.map((e) => expect.objectContaining(e))) : expect.any(Array), + }), noPermission: { message: expect.stringContaining('Not found or no'), }, diff --git a/server/test/medium/specs/exif/audio-video.spec.ts b/server/test/medium/specs/exif/audio-video.spec.ts new file mode 100644 index 0000000000..430e7826f9 --- /dev/null +++ b/server/test/medium/specs/exif/audio-video.spec.ts @@ -0,0 +1,142 @@ +import { Kysely } from 'kysely'; +import { resolve } from 'node:path'; +import { + AacProfile, + AssetType, + ColorMatrix, + ColorPrimaries, + ColorTransfer, + DvProfile, + DvSignalCompatibility, + H264Profile, + HevcProfile, +} from 'src/enum'; +import { DB } from 'src/schema'; +import { ExifTestContext, testAssetsDir } from 'test/medium.factory'; +import { getKyselyDB } from 'test/utils'; + +let database: Kysely; + +beforeAll(async () => { + database = await getKyselyDB(); +}); + +const fixtures = [ + { + file: 'eiffel-tower.mp4', + video: { + codecName: 'h264', + formatName: 'mov,mp4,m4a,3gp,3g2,mj2', + formatLongName: 'QuickTime / MOV', + pixelFormat: 'yuv420p', + bitrate: 5_128_622, + frameCount: 557, + timeBase: 90_000, + index: 0, + profile: H264Profile.High, + level: 40, + colorPrimaries: ColorPrimaries.Smpte170M, + colorTransfer: ColorTransfer.Smpte170M, + colorMatrix: ColorMatrix.Smpte170M, + dvProfile: null, + dvLevel: null, + dvBlSignalCompatibilityId: null, + }, + audio: { codecName: 'aac', bitrate: 125_629, index: 1, profile: AacProfile.Lc }, + keyframes: { + totalDuration: 2_012_441, + packetCount: 557, + outputFrames: 557, + pts: [0, 462_502, 925_004, 1_210_454, 1_387_506, 1_542_878, 1_850_008], + accDuration: [3613, 466_077, 928_541, 1_213_968, 1_391_005, 1_546_364, 1_853_469], + ownDuration: [3613, 3613, 3613, 3613, 3613, 3613, 3613], + }, + }, + { + file: 'waterfall.mp4', + video: { + codecName: 'hevc', + formatName: 'mov,mp4,m4a,3gp,3g2,mj2', + formatLongName: 'QuickTime / MOV', + pixelFormat: 'yuvj420p', + bitrate: 43_363_499, + frameCount: 309, + timeBase: 90_000, + index: 2, + profile: HevcProfile.Main, + level: 156, + colorPrimaries: ColorPrimaries.Bt709, + colorTransfer: ColorTransfer.Bt709, + colorMatrix: ColorMatrix.Bt709, + dvProfile: null, + dvLevel: null, + dvBlSignalCompatibilityId: null, + }, + audio: { codecName: 'aac', bitrate: 191_878, index: 1, profile: null }, + keyframes: { + totalDuration: 932_286, + packetCount: 309, + outputFrames: 309, + pts: [0, 89_987, 179_974, 269_961, 359_948, 449_936, 539_923, 629_910, 725_166, 815_273, 905_295], + accDuration: [2999, 92_987, 182_974, 272_961, 362_948, 452_934, 542_922, 632_909, 728_175, 818_274, 908_296], + ownDuration: [2999, 3000, 3000, 3000, 3000, 2998, 2999, 2999, 3009, 3001, 3001], + }, + }, + { + file: 'train.mov', + video: { + codecName: 'hevc', + formatName: 'mov,mp4,m4a,3gp,3g2,mj2', + formatLongName: 'QuickTime / MOV', + pixelFormat: 'yuv420p10le', + bitrate: 12_595_191, + frameCount: 1229, + timeBase: 600, + index: 0, + profile: HevcProfile.Main10, + level: 123, + colorPrimaries: ColorPrimaries.Bt2020, + colorTransfer: ColorTransfer.AribStdB67, + colorMatrix: ColorMatrix.Bt2020Nc, + dvProfile: DvProfile.Dvhe08, + dvLevel: 5, + dvBlSignalCompatibilityId: DvSignalCompatibility.Hlg, + }, + audio: { codecName: 'aac', bitrate: 175_477, index: 1, profile: AacProfile.Lc }, + keyframes: { + totalDuration: 12_290, + packetCount: 1229, + outputFrames: 1303, + pts: [ + 0, 601, 1201, 1802, 2402, 3003, 3604, 4204, 4805, 5405, 6006, 6607, 7207, 7808, 8408, 9009, 9609, 10_210, + 10_811, 11_411, 12_062, 12_703, + ], + accDuration: [ + 10, 580, 1180, 1780, 2380, 2980, 3580, 4180, 4780, 5380, 5980, 6580, 7180, 7780, 8380, 8980, 9580, 10_180, + 10_780, 11_380, 11_780, 12_100, + ], + ownDuration: [10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10], + }, + }, +]; + +const isExpected = (name: T, id: string, expected: Omit) => { + const { table, ref } = database.dynamic; + const res = database.selectFrom(table(name).as('t')).selectAll().where(ref('assetId'), '=', id).executeTakeFirst(); + return expect(res).resolves.toEqual({ ...expected, assetId: id }); +}; + +describe('video metadata extraction', () => { + it.each(fixtures)('$file', async ({ file, video, audio, keyframes }) => { + const ctx = new ExifTestContext(database); + const { user } = await ctx.newUser(); + const originalPath = resolve(testAssetsDir, 'videos', file); + const { asset } = await ctx.newAsset({ ownerId: user.id, originalPath, type: AssetType.Video }); + + await ctx.sut.handleMetadataExtraction({ id: asset.id }); + + await isExpected('asset_audio', asset.id, audio); + await isExpected('asset_video', asset.id, video); + await isExpected('asset_keyframe', asset.id, keyframes); + }); +}); diff --git a/server/test/medium/specs/repositories/asset.repository.spec.ts b/server/test/medium/specs/repositories/asset.repository.spec.ts index 896489672e..2e449ae801 100644 --- a/server/test/medium/specs/repositories/asset.repository.spec.ts +++ b/server/test/medium/specs/repositories/asset.repository.spec.ts @@ -98,10 +98,10 @@ describe(AssetRepository.name, () => { .executeTakeFirstOrThrow(), ).resolves.toEqual({ lockedProperties: ['dateTimeOriginal'] }); - await sut.upsertExif( - { assetId: asset.id, lockedProperties: ['description'] }, - { lockedPropertiesBehavior: 'append' }, - ); + await sut.upsertExif({ + exif: { assetId: asset.id, lockedProperties: ['description'] }, + lockedPropertiesBehavior: 'append', + }); await expect( ctx.database @@ -130,10 +130,10 @@ describe(AssetRepository.name, () => { .executeTakeFirstOrThrow(), ).resolves.toEqual({ lockedProperties: ['dateTimeOriginal', 'description'] }); - await sut.upsertExif( - { assetId: asset.id, lockedProperties: ['description'] }, - { lockedPropertiesBehavior: 'append' }, - ); + await sut.upsertExif({ + exif: { assetId: asset.id, lockedProperties: ['description'] }, + lockedPropertiesBehavior: 'append', + }); await expect( ctx.database diff --git a/server/test/medium/specs/services/user.service.spec.ts b/server/test/medium/specs/services/user.service.spec.ts index 2250034eea..c8c990a8da 100644 --- a/server/test/medium/specs/services/user.service.spec.ts +++ b/server/test/medium/specs/services/user.service.spec.ts @@ -48,7 +48,7 @@ describe(UserService.name, () => { ctx.getMock(EventRepository).emit.mockResolvedValue(); const user = mediumFactory.userInsert(); await expect(sut.createUser({ name: 'Test', email: user.email })).resolves.toMatchObject({ email: user.email }); - await expect(sut.createUser({ name: 'Test', email: user.email })).rejects.toThrow('User exists'); + await expect(sut.createUser({ name: 'Test', email: user.email })).rejects.toThrow('Email is not available'); }); it('should not return password', async () => { diff --git a/server/test/medium/specs/sync/sync-album-asset-exif.spec.ts b/server/test/medium/specs/sync/sync-album-asset-exif.spec.ts index 1865fc2c80..8e1529edb0 100644 --- a/server/test/medium/specs/sync/sync-album-asset-exif.spec.ts +++ b/server/test/medium/specs/sync/sync-album-asset-exif.spec.ts @@ -289,13 +289,13 @@ describe(SyncRequestType.AlbumAssetExifsV1, () => { // update the asset const assetRepository = ctx.get(AssetRepository); - await assetRepository.upsertExif( - updateLockedColumns({ + await assetRepository.upsertExif({ + exif: updateLockedColumns({ assetId: asset.id, city: 'New City', }), - { lockedPropertiesBehavior: 'append' }, - ); + lockedPropertiesBehavior: 'append', + }); await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1])).resolves.toEqual([ { @@ -350,13 +350,13 @@ describe(SyncRequestType.AlbumAssetExifsV1, () => { // update the asset const assetRepository = ctx.get(AssetRepository); - await assetRepository.upsertExif( - updateLockedColumns({ + await assetRepository.upsertExif({ + exif: updateLockedColumns({ assetId: assetDelayedExif.id, city: 'Delayed Exif', }), - { lockedPropertiesBehavior: 'append' }, - ); + lockedPropertiesBehavior: 'append', + }); await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1])).resolves.toEqual([ { diff --git a/server/test/repositories/media.repository.mock.ts b/server/test/repositories/media.repository.mock.ts index bd8deb4b3a..a1fc55cc3d 100644 --- a/server/test/repositories/media.repository.mock.ts +++ b/server/test/repositories/media.repository.mock.ts @@ -11,6 +11,14 @@ export const newMediaRepositoryMock = (): Mocked v4(); export const newUuids = () => @@ -250,5 +251,9 @@ export const factory = { badRequest: (message: any = null) => ({ message: message ?? expect.anything(), }), + validationError: (errors?: ReadonlyArray<{ path: ReadonlyArray; message: string }>) => ({ + message: 'Validation failed', + errors: errors ? expect.arrayContaining(errors.map((e) => expect.objectContaining(e))) : expect.any(Array), + }), }, }; diff --git a/web/eslint.config.js b/web/eslint.config.js index a75aa9ed05..e457be29ba 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -1,6 +1,7 @@ import js from '@eslint/js'; import tslintPluginCompat from '@koddsson/eslint-plugin-tscompat'; import prettier from 'eslint-config-prettier'; +import eslintPluginBetterTailwindcss from 'eslint-plugin-better-tailwindcss'; import eslintPluginCompat from 'eslint-plugin-compat'; import eslintPluginSvelte from 'eslint-plugin-svelte'; import eslintPluginUnicorn from 'eslint-plugin-unicorn'; @@ -18,7 +19,6 @@ export default typescriptEslint.config( ...eslintPluginSvelte.configs.recommended, eslintPluginUnicorn.configs.recommended, js.configs.recommended, - prettier, { plugins: { tscompat: tslintPluginCompat, @@ -134,6 +134,18 @@ export default typescriptEslint.config( }, }, { + extends: [eslintPluginBetterTailwindcss.configs.recommended], + settings: { + 'better-tailwindcss': { + entryPoint: 'src/app.css', + }, + }, + + rules: { + 'better-tailwindcss/enforce-consistent-line-wrapping': 'off', + 'better-tailwindcss/no-unknown-classes': 'off', + }, + files: ['**/*.svelte'], languageOptions: { diff --git a/web/package.json b/web/package.json index 32b44a4645..daaa74d9e9 100644 --- a/web/package.json +++ b/web/package.json @@ -51,6 +51,7 @@ "lodash-es": "^4.17.21", "luxon": "^3.4.4", "maplibre-gl": "^5.6.2", + "media-chrome": "^4.19.0", "pmtiles": "^4.3.0", "qrcode": "^1.5.4", "simple-icons": "^16.0.0", @@ -76,7 +77,7 @@ "@sveltejs/enhanced-img": "^0.10.4", "@sveltejs/kit": "^2.56.1", "@sveltejs/vite-plugin-svelte": "7.0.0", - "@tailwindcss/vite": "^4.2.2", + "@tailwindcss/vite": "^4.2.4", "@testing-library/jest-dom": "^6.4.2", "@testing-library/svelte": "^5.2.8", "@testing-library/user-event": "^14.5.2", @@ -91,6 +92,7 @@ "dotenv": "^17.0.0", "eslint": "^10.2.1", "eslint-config-prettier": "^10.1.8", + "eslint-plugin-better-tailwindcss": "^4.5.0", "eslint-plugin-compat": "^7.0.0", "eslint-plugin-svelte": "^3.12.4", "eslint-plugin-unicorn": "^64.0.0", @@ -104,7 +106,7 @@ "svelte": "5.55.2", "svelte-check": "^4.4.6", "svelte-eslint-parser": "^1.3.3", - "tailwindcss": "^4.2.2", + "tailwindcss": "^4.2.4", "typescript": "^6.0.0", "typescript-eslint": "^8.45.0", "vite": "^8.0.0", diff --git a/web/src/app.css b/web/src/app.css index 0a0187f9fd..07226be41f 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -1,8 +1,38 @@ @import 'tailwindcss'; @import '@immich/ui/theme/default.css'; + @source "../node_modules/@immich/ui"; /* @import '../../../ui/packages/ui/dist/theme/default.css'; */ +@custom-variant dark (&:where(.dark, .dark *):not(.light)); + +@theme inline { + --color-immich-primary: rgb(var(--immich-primary)); + --color-immich-bg: rgb(var(--immich-bg)); + --color-immich-fg: rgb(var(--immich-fg)); + --color-immich-gray: rgb(var(--immich-gray)); + + --color-immich-dark-primary: rgb(var(--immich-dark-primary)); + --color-immich-dark-bg: rgb(var(--immich-dark-bg)); + --color-immich-dark-fg: rgb(var(--immich-dark-fg)); + --color-immich-dark-gray: rgb(var(--immich-dark-gray)); +} + +@theme { + --font-sans: 'GoogleSans', sans-serif; + --font-mono: 'GoogleSansCode', monospace; + + --spacing-18: 4.5rem; + + --breakpoint-tall: 800px; + --breakpoint-2xl: 1535px; + --breakpoint-xl: 1279px; + --breakpoint-lg: 1023px; + --breakpoint-md: 767px; + --breakpoint-sm: 639px; + --breakpoint-sidebar: 850px; +} + @utility immich-form-input { @apply bg-gray-100 ring-1 ring-gray-200 transition outline-none focus-within:ring-1 disabled:cursor-not-allowed dark:bg-gray-800 dark:ring-neutral-900 flex w-full items-center rounded-lg disabled:bg-gray-300 disabled:text-dark dark:disabled:bg-gray-900 dark:disabled:text-gray-200 flex-1 py-2.5 text-base pl-4 pr-4; } @@ -34,35 +64,6 @@ grid-template-columns: repeat(auto-fill, minmax(min(calc(var(--spacing) * --value(number)), 100%), 1fr)); } -@custom-variant dark (&:where(.dark, .dark *):not(.light)); - -@theme inline { - --color-immich-primary: rgb(var(--immich-primary)); - --color-immich-bg: rgb(var(--immich-bg)); - --color-immich-fg: rgb(var(--immich-fg)); - --color-immich-gray: rgb(var(--immich-gray)); - - --color-immich-dark-primary: rgb(var(--immich-dark-primary)); - --color-immich-dark-bg: rgb(var(--immich-dark-bg)); - --color-immich-dark-fg: rgb(var(--immich-dark-fg)); - --color-immich-dark-gray: rgb(var(--immich-dark-gray)); -} - -@theme { - --font-sans: 'GoogleSans', sans-serif; - --font-mono: 'GoogleSansCode', monospace; - - --spacing-18: 4.5rem; - - --breakpoint-tall: 800px; - --breakpoint-2xl: 1535px; - --breakpoint-xl: 1279px; - --breakpoint-lg: 1023px; - --breakpoint-md: 767px; - --breakpoint-sm: 639px; - --breakpoint-sidebar: 850px; -} - @layer base { :root { /* light */ @@ -168,7 +169,7 @@ .maplibregl-popup { .maplibregl-popup-tip { - @apply border-t-subtle! translate-y-[-1px]; + @apply border-t-subtle! -translate-y-px; } .maplibregl-popup-content { diff --git a/web/src/lib/components/AdaptiveImage.svelte b/web/src/lib/components/AdaptiveImage.svelte index a61fb13029..39bc4516b7 100644 --- a/web/src/lib/components/AdaptiveImage.svelte +++ b/web/src/lib/components/AdaptiveImage.svelte @@ -148,11 +148,11 @@ }); -
+
{@render backdrop?.()}
- + {:else if show.spinner} {/if} @@ -185,7 +185,7 @@ {/if} {#if show.brokenAsset} - + {/if} {#if show.preview} diff --git a/web/src/lib/components/AdminCard.svelte b/web/src/lib/components/AdminCard.svelte index 4aaf890ca4..02f1195ddb 100644 --- a/web/src/lib/components/AdminCard.svelte +++ b/web/src/lib/components/AdminCard.svelte @@ -15,7 +15,7 @@ -
+
{title} diff --git a/web/src/lib/components/ApiKeyPermissionsPicker.svelte b/web/src/lib/components/ApiKeyPermissionsPicker.svelte index 62283bcf74..859c20da80 100644 --- a/web/src/lib/components/ApiKeyPermissionsPicker.svelte +++ b/web/src/lib/components/ApiKeyPermissionsPicker.svelte @@ -50,7 +50,7 @@