diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index f1d16f7e6b..6b51e01f19 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -60,12 +60,12 @@ services: redis: container_name: immich_redis - image: redis:6.2-alpine@sha256:51d6c56749a4243096327e3fb964a48ed92254357108449cb6e23999c37773c5 + image: registry.hub.docker.com/library/redis:6.2-alpine@sha256:51d6c56749a4243096327e3fb964a48ed92254357108449cb6e23999c37773c5 restart: always database: container_name: immich_postgres - image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0 + image: registry.hub.docker.com/tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0 environment: POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_USER: ${DB_USERNAME} diff --git a/docs/docs/features/libraries.md b/docs/docs/features/libraries.md index 64ad15fb9d..1433f604bc 100644 --- a/docs/docs/features/libraries.md +++ b/docs/docs/features/libraries.md @@ -74,10 +74,7 @@ Some basic examples: This feature - currently hidden in the config file - is considered experimental and for advanced users only. If enabled, it will allow automatic watching of the filesystem which means new assets are automatically imported to Immich without needing to rescan. Deleted assets are, as always, marked as offline and can be removed with the "Remove offline files" button. -If your photos are on a network drive you will likely have to enable filesystem polling. The performance hit for polling large libraries is currently unknown, feel free to test this feature and report back. In addition to the boolean feature flag, the configuration file allows customization of the following parameters, please see the [chokidar documentation](https://github.com/paulmillr/chokidar?tab=readme-ov-file#performance) for reference. - -- `usePolling` (default: `false`). -- `interval`. (default: 10000). When using polling, this is how often (in milliseconds) the filesystem is polled. +If your photos are on a network drive, automatic file watching likely won't work. In that case, you will have to rely on a periodic library refresh to pull in your changes. ### Nightly job diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 649893ec89..edf5a1a09d 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -17,6 +17,7 @@ "@types/pg": "^8.11.0", "@types/supertest": "^6.0.2", "@vitest/coverage-v8": "^1.3.0", + "exiftool-vendored": "^24.5.0", "luxon": "^3.4.4", "pg": "^8.11.3", "socket.io-client": "^4.7.4", @@ -594,6 +595,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@photostructure/tz-lookup": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-9.0.2.tgz", + "integrity": "sha512-H8+tTt7ilJNkFyb+QgPnLEGUjQzGwiMb9n7lwRZNBgSKL3VZs9AkjI1E//FcwPjNafwAH932U92+xTqJiF3Bbw==", + "dev": true + }, "node_modules/@playwright/test": { "version": "1.41.2", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.41.2.tgz", @@ -1074,6 +1081,15 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/batch-cluster": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/batch-cluster/-/batch-cluster-13.0.0.tgz", + "integrity": "sha512-EreW0Vi8TwovhYUHBXXRA5tthuU2ynGsZFlboyMJHCCUXYa2AjgwnE3ubBOJs2xJLcuXFJbi6c/8pH5+FVj8Og==", + "dev": true, + "engines": { + "node": ">=14" + } + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -1391,6 +1407,43 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/exiftool-vendored": { + "version": "24.5.0", + "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-24.5.0.tgz", + "integrity": "sha512-uLGYfeshak3mYn2ucCsebXfNFdOpeAULlMb84wiJv+4B236n+ypgK/vr8bJgAcsIPSRJXFSz9WonvjjQYYqR3w==", + "dev": true, + "dependencies": { + "@photostructure/tz-lookup": "^9.0.1", + "@types/luxon": "^3.4.2", + "batch-cluster": "^13.0.0", + "he": "^1.2.0", + "luxon": "^3.4.4" + }, + "optionalDependencies": { + "exiftool-vendored.exe": "12.76.0", + "exiftool-vendored.pl": "12.76.0" + } + }, + "node_modules/exiftool-vendored.exe": { + "version": "12.76.0", + "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.76.0.tgz", + "integrity": "sha512-lbKPPs31qpjnhFiMRaVxJX+iNcJ+p0NrRSFLHHaX6KTsfMba6e5i6NykSvU3wMiafzUTef1Fen3XQ+8n1tjjNw==", + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/exiftool-vendored.pl": { + "version": "12.76.0", + "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.76.0.tgz", + "integrity": "sha512-4DxqgnvL71YziVoY27ZMgVfLAWDH3pQLljuV5+ffTnTPvz/BWeV+/bVFwRvDqCD3lkCWds0YfVcsycfJgbQ5fA==", + "dev": true, + "optional": true, + "os": [ + "!win32" + ] + }, "node_modules/fast-safe-stringify": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", @@ -1584,6 +1637,15 @@ "node": ">= 0.4" } }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "bin": { + "he": "bin/he" + } + }, "node_modules/hexoid": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz", diff --git a/e2e/package.json b/e2e/package.json index 7bbdfd1d9d..26a1d7ef3a 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -21,6 +21,7 @@ "@types/pg": "^8.11.0", "@types/supertest": "^6.0.2", "@vitest/coverage-v8": "^1.3.0", + "exiftool-vendored": "^24.5.0", "luxon": "^3.4.4", "pg": "^8.11.3", "socket.io-client": "^4.7.4", diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index db1821260b..e1f4450312 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -1,16 +1,39 @@ import { AssetFileUploadResponseDto, AssetResponseDto, + AssetTypeEnum, LoginResponseDto, SharedLinkType, } from '@immich/sdk'; +import { exiftool } from 'exiftool-vendored'; import { DateTime } from 'luxon'; +import { createHash } from 'node:crypto'; +import { readFile, writeFile } from 'node:fs/promises'; +import { basename, join } from 'node:path'; import { Socket } from 'socket.io-client'; import { createUserDto, uuidDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; -import { apiUtils, app, dbUtils } from 'src/utils'; +import { + apiUtils, + app, + dbUtils, + tempDir, + testAssetDir, + wsUtils, +} from 'src/utils'; import request from 'supertest'; -import { beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const locationAssetFilepath = `${testAssetDir}/metadata/gps-position/thompson-springs.jpg`; + +const sha1 = (bytes: Buffer) => + createHash('sha1').update(bytes).digest('base64'); + +const readTags = async (bytes: Buffer, filename: string) => { + const filepath = join(tempDir, filename); + await writeFile(filepath, bytes); + return exiftool.read(filepath); +}; const today = DateTime.fromObject({ year: 2023, @@ -24,25 +47,36 @@ describe('/asset', () => { let user1: LoginResponseDto; let user2: LoginResponseDto; let userStats: LoginResponseDto; - let asset1: AssetFileUploadResponseDto; - let asset2: AssetFileUploadResponseDto; - let asset3: AssetFileUploadResponseDto; - let asset4: AssetFileUploadResponseDto; // user2 asset - let asset5: AssetFileUploadResponseDto; - let asset6: AssetFileUploadResponseDto; + let user1Assets: AssetFileUploadResponseDto[]; + let user2Assets: AssetFileUploadResponseDto[]; + let assetLocation: AssetFileUploadResponseDto; let ws: Socket; beforeAll(async () => { apiUtils.setup(); await dbUtils.reset(); admin = await apiUtils.adminSetup({ onboarding: false }); - [user1, user2, userStats] = await Promise.all([ + + [ws, user1, user2, userStats] = await Promise.all([ + wsUtils.connect(admin.accessToken), apiUtils.userSetup(admin.accessToken, createUserDto.user1), apiUtils.userSetup(admin.accessToken, createUserDto.user2), apiUtils.userSetup(admin.accessToken, createUserDto.user3), ]); - [asset1, asset2, asset3, asset4, asset5, asset6] = await Promise.all([ + // asset location + assetLocation = await apiUtils.createAsset( + admin.accessToken, + {}, + { + filename: 'thompson-springs.jpg', + bytes: await readFile(locationAssetFilepath), + }, + ); + + await wsUtils.waitForEvent({ event: 'upload', assetId: assetLocation.id }); + + user1Assets = await Promise.all([ apiUtils.createAsset(user1.accessToken), apiUtils.createAsset(user1.accessToken), apiUtils.createAsset( @@ -56,10 +90,13 @@ describe('/asset', () => { }, { filename: 'example.mp4' }, ), - apiUtils.createAsset(user2.accessToken), apiUtils.createAsset(user1.accessToken), apiUtils.createAsset(user1.accessToken), + ]); + user2Assets = await Promise.all([apiUtils.createAsset(user2.accessToken)]); + + await Promise.all([ // stats apiUtils.createAsset(userStats.accessToken), apiUtils.createAsset(userStats.accessToken, { isFavorite: true }), @@ -77,7 +114,14 @@ describe('/asset', () => { const person1 = await apiUtils.createPerson(user1.accessToken, { name: 'Test Person', }); - await dbUtils.createFace({ assetId: asset1.id, personId: person1.id }); + await dbUtils.createFace({ + assetId: user1Assets[0].id, + personId: person1.id, + }); + }, 30_000); + + afterAll(() => { + wsUtils.disconnect(ws); }); describe('GET /asset/:id', () => { @@ -99,7 +143,7 @@ describe('/asset', () => { it('should require access', async () => { const { status, body } = await request(app) - .get(`/asset/${asset4.id}`) + .get(`/asset/${user2Assets[0].id}`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(400); expect(body).toEqual(errorDto.noPermission); @@ -107,33 +151,33 @@ describe('/asset', () => { it('should get the asset info', async () => { const { status, body } = await request(app) - .get(`/asset/${asset1.id}`) + .get(`/asset/${user1Assets[0].id}`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); - expect(body).toMatchObject({ id: asset1.id }); + expect(body).toMatchObject({ id: user1Assets[0].id }); }); it('should work with a shared link', async () => { const sharedLink = await apiUtils.createSharedLink(user1.accessToken, { type: SharedLinkType.Individual, - assetIds: [asset1.id], + assetIds: [user1Assets[0].id], }); const { status, body } = await request(app).get( - `/asset/${asset1.id}?key=${sharedLink.key}`, + `/asset/${user1Assets[0].id}?key=${sharedLink.key}`, ); expect(status).toBe(200); - expect(body).toMatchObject({ id: asset1.id }); + expect(body).toMatchObject({ id: user1Assets[0].id }); }); it('should not send people data for shared links for un-authenticated users', async () => { const { status, body } = await request(app) - .get(`/asset/${asset1.id}`) + .get(`/asset/${user1Assets[0].id}`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toEqual(200); expect(body).toMatchObject({ - id: asset1.id, + id: user1Assets[0].id, isFavorite: false, people: [ { @@ -148,11 +192,11 @@ describe('/asset', () => { const sharedLink = await apiUtils.createSharedLink(user1.accessToken, { type: SharedLinkType.Individual, - assetIds: [asset1.id], + assetIds: [user1Assets[0].id], }); const data = await request(app).get( - `/asset/${asset1.id}?key=${sharedLink.key}`, + `/asset/${user1Assets[0].id}?key=${sharedLink.key}`, ); expect(data.status).toBe(200); expect(data.body).toMatchObject({ people: [] }); @@ -246,11 +290,11 @@ describe('/asset', () => { const assets: AssetResponseDto[] = body; expect(assets.length).toBe(1); expect(assets[0].ownerId).toBe(user1.userId); - // - // assets owned by user2 - expect(assets[0].id).not.toBe(asset4.id); + // assets owned by user1 - expect([asset1.id, asset2.id, asset3.id]).toContain(assets[0].id); + expect([user1Assets.map(({ id }) => id)]).toContain(assets[0].id); + // assets owned by user2 + expect([user1Assets.map(({ id }) => id)]).not.toContain(assets[0].id); }); it.each(Array(10))('should return 2 random assets', async () => { @@ -266,9 +310,9 @@ describe('/asset', () => { for (const asset of assets) { expect(asset.ownerId).toBe(user1.userId); // assets owned by user1 - expect([asset1.id, asset2.id, asset3.id]).toContain(asset.id); + expect([user1Assets.map(({ id }) => id)]).toContain(asset.id); // assets owned by user2 - expect(asset.id).not.toBe(asset4.id); + expect([user2Assets.map(({ id }) => id)]).not.toContain(asset.id); } }); @@ -280,7 +324,9 @@ describe('/asset', () => { .set('Authorization', `Bearer ${user2.accessToken}`); expect(status).toBe(200); - expect(body).toEqual([expect.objectContaining({ id: asset4.id })]); + expect(body).toEqual([ + expect.objectContaining({ id: user2Assets[0].id }), + ]); }, ); @@ -312,44 +358,50 @@ describe('/asset', () => { it('should require access', async () => { const { status, body } = await request(app) - .put(`/asset/${asset4.id}`) + .put(`/asset/${user2Assets[0].id}`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(400); expect(body).toEqual(errorDto.noPermission); }); it('should favorite an asset', async () => { - const before = await apiUtils.getAssetInfo(user1.accessToken, asset1.id); + const before = await apiUtils.getAssetInfo( + user1.accessToken, + user1Assets[0].id, + ); expect(before.isFavorite).toBe(false); const { status, body } = await request(app) - .put(`/asset/${asset1.id}`) + .put(`/asset/${user1Assets[0].id}`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ isFavorite: true }); - expect(body).toMatchObject({ id: asset1.id, isFavorite: true }); + expect(body).toMatchObject({ id: user1Assets[0].id, isFavorite: true }); expect(status).toEqual(200); }); it('should archive an asset', async () => { - const before = await apiUtils.getAssetInfo(user1.accessToken, asset1.id); + const before = await apiUtils.getAssetInfo( + user1.accessToken, + user1Assets[0].id, + ); expect(before.isArchived).toBe(false); const { status, body } = await request(app) - .put(`/asset/${asset1.id}`) + .put(`/asset/${user1Assets[0].id}`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ isArchived: true }); - expect(body).toMatchObject({ id: asset1.id, isArchived: true }); + expect(body).toMatchObject({ id: user1Assets[0].id, isArchived: true }); expect(status).toEqual(200); }); it('should update date time original', async () => { const { status, body } = await request(app) - .put(`/asset/${asset1.id}`) + .put(`/asset/${user1Assets[0].id}`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ dateTimeOriginal: '2023-11-19T18:11:00.000-07:00' }); expect(body).toMatchObject({ - id: asset1.id, + id: user1Assets[0].id, exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-20T01:11:00.000Z', }), @@ -371,7 +423,7 @@ describe('/asset', () => { { latitude: 12, longitude: 181 }, ]) { const { status, body } = await request(app) - .put(`/asset/${asset1.id}`) + .put(`/asset/${user1Assets[0].id}`) .send(test) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(400); @@ -381,12 +433,12 @@ describe('/asset', () => { it('should update gps data', async () => { const { status, body } = await request(app) - .put(`/asset/${asset1.id}`) + .put(`/asset/${user1Assets[0].id}`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ latitude: 12, longitude: 12 }); expect(body).toMatchObject({ - id: asset1.id, + id: user1Assets[0].id, exifInfo: expect.objectContaining({ latitude: 12, longitude: 12 }), }); expect(status).toEqual(200); @@ -394,11 +446,11 @@ describe('/asset', () => { it('should set the description', async () => { const { status, body } = await request(app) - .put(`/asset/${asset1.id}`) + .put(`/asset/${user1Assets[0].id}`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ description: 'Test asset description' }); expect(body).toMatchObject({ - id: asset1.id, + id: user1Assets[0].id, exifInfo: expect.objectContaining({ description: 'Test asset description', }), @@ -408,12 +460,12 @@ describe('/asset', () => { it('should return tagged people', async () => { const { status, body } = await request(app) - .put(`/asset/${asset1.id}`) + .put(`/asset/${user1Assets[0].id}`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ isFavorite: true }); expect(status).toEqual(200); expect(body).toMatchObject({ - id: asset1.id, + id: user1Assets[0].id, isFavorite: true, people: [ { @@ -478,4 +530,279 @@ describe('/asset', () => { expect(after.isTrashed).toBe(true); }); }); + + describe('POST /asset/upload', () => { + const tests = [ + { + input: 'formats/jpg/el_torcal_rocks.jpg', + expected: { + type: AssetTypeEnum.Image, + originalFileName: 'el_torcal_rocks', + resized: true, + exifInfo: { + dateTimeOriginal: '2012-08-05T11:39:59.000Z', + exifImageWidth: 512, + exifImageHeight: 341, + latitude: null, + longitude: null, + focalLength: 75, + iso: 200, + fNumber: 11, + exposureTime: '1/160', + fileSizeInByte: 53_493, + make: 'SONY', + model: 'DSLR-A550', + orientation: null, + description: 'SONY DSC', + }, + }, + }, + { + input: 'formats/heic/IMG_2682.heic', + expected: { + type: AssetTypeEnum.Image, + originalFileName: 'IMG_2682', + resized: true, + fileCreatedAt: '2019-03-21T16:04:22.348Z', + exifInfo: { + dateTimeOriginal: '2019-03-21T16:04:22.348Z', + exifImageWidth: 4032, + exifImageHeight: 3024, + latitude: 41.2203, + longitude: -96.071_625, + make: 'Apple', + model: 'iPhone 7', + lensModel: 'iPhone 7 back camera 3.99mm f/1.8', + fileSizeInByte: 880_703, + exposureTime: '1/887', + iso: 20, + focalLength: 3.99, + fNumber: 1.8, + timeZone: 'America/Chicago', + }, + }, + }, + { + input: 'formats/png/density_plot.png', + expected: { + type: AssetTypeEnum.Image, + originalFileName: 'density_plot', + resized: true, + exifInfo: { + exifImageWidth: 800, + exifImageHeight: 800, + latitude: null, + longitude: null, + fileSizeInByte: 25_408, + }, + }, + }, + { + input: 'formats/raw/Nikon/D80/glarus.nef', + expected: { + type: AssetTypeEnum.Image, + originalFileName: 'glarus', + resized: true, + fileCreatedAt: '2010-07-20T17:27:12.000Z', + exifInfo: { + make: 'NIKON CORPORATION', + model: 'NIKON D80', + exposureTime: '1/200', + fNumber: 10, + focalLength: 18, + iso: 100, + fileSizeInByte: 9_057_784, + dateTimeOriginal: '2010-07-20T17:27:12.000Z', + latitude: null, + longitude: null, + orientation: '1', + }, + }, + }, + { + input: 'formats/raw/Nikon/D700/philadelphia.nef', + expected: { + type: AssetTypeEnum.Image, + originalFileName: 'philadelphia', + resized: true, + fileCreatedAt: '2016-09-22T22:10:29.060Z', + exifInfo: { + make: 'NIKON CORPORATION', + model: 'NIKON D700', + exposureTime: '1/400', + fNumber: 11, + focalLength: 85, + iso: 200, + fileSizeInByte: 15_856_335, + dateTimeOriginal: '2016-09-22T22:10:29.060Z', + latitude: null, + longitude: null, + orientation: '1', + timeZone: 'UTC-5', + }, + }, + }, + ]; + + for (const { input, expected } of tests) { + it(`should generate a thumbnail for ${input}`, async () => { + const filepath = join(testAssetDir, input); + const { id, duplicate } = await apiUtils.createAsset( + admin.accessToken, + {}, + { bytes: await readFile(filepath), filename: basename(filepath) }, + ); + + expect(duplicate).toBe(false); + + await wsUtils.waitForEvent({ event: 'upload', assetId: id }); + + const asset = await apiUtils.getAssetInfo(admin.accessToken, id); + + expect(asset.exifInfo).toBeDefined(); + expect(asset.exifInfo).toMatchObject(expected.exifInfo); + expect(asset).toMatchObject(expected); + }); + } + + it('should handle a duplicate', async () => { + const filepath = 'formats/jpeg/el_torcal_rocks.jpeg'; + const { duplicate } = await apiUtils.createAsset( + admin.accessToken, + {}, + { + bytes: await readFile(join(testAssetDir, filepath)), + filename: basename(filepath), + }, + ); + + expect(duplicate).toBe(true); + }); + + // These hashes were created by copying the image files to a Samsung phone, + // exporting the video from Samsung's stock Gallery app, and hashing them locally. + // This ensures that immich+exiftool are extracting the videos the same way Samsung does. + // DO NOT assume immich+exiftool are doing things correctly and just copy whatever hash it gives + // into the test here. + const motionTests = [ + { + filepath: 'formats/motionphoto/Samsung One UI 5.jpg', + checksum: 'fr14niqCq6N20HB8rJYEvpsUVtI=', + }, + { + filepath: 'formats/motionphoto/Samsung One UI 6.jpg', + checksum: 'lT9Uviw/FFJYCjfIxAGPTjzAmmw=', + }, + { + filepath: 'formats/motionphoto/Samsung One UI 6.heic', + checksum: '/ejgzywvgvzvVhUYVfvkLzFBAF0=', + }, + ]; + + for (const { filepath, checksum } of motionTests) { + it(`should extract motionphoto video from ${filepath}`, async () => { + const response = await apiUtils.createAsset( + admin.accessToken, + {}, + { + bytes: await readFile(join(testAssetDir, filepath)), + filename: basename(filepath), + }, + ); + + await wsUtils.waitForEvent({ event: 'upload', assetId: response.id }); + + expect(response.duplicate).toBe(false); + + const asset = await apiUtils.getAssetInfo( + admin.accessToken, + response.id, + ); + expect(asset.livePhotoVideoId).toBeDefined(); + + const video = await apiUtils.getAssetInfo( + admin.accessToken, + asset.livePhotoVideoId as string, + ); + expect(video.checksum).toStrictEqual(checksum); + }); + } + }); + + describe('GET /asset/thumbnail/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get( + `/asset/thumbnail/${assetLocation.id}`, + ); + + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should not include gps data for webp thumbnails', async () => { + const { status, body, type } = await request(app) + .get(`/asset/thumbnail/${assetLocation.id}?format=WEBP`) + .set('Authorization', `Bearer ${admin.accessToken}`); + + await wsUtils.waitForEvent({ + event: 'upload', + assetId: assetLocation.id, + }); + + expect(status).toBe(200); + expect(body).toBeDefined(); + expect(type).toBe('image/webp'); + + const exifData = await readTags(body, 'thumbnail.webp'); + expect(exifData).not.toHaveProperty('GPSLongitude'); + expect(exifData).not.toHaveProperty('GPSLatitude'); + }); + + it('should not include gps data for jpeg thumbnails', async () => { + const { status, body, type } = await request(app) + .get(`/asset/thumbnail/${assetLocation.id}?format=JPEG`) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toBeDefined(); + expect(type).toBe('image/jpeg'); + + const exifData = await readTags(body, 'thumbnail.jpg'); + expect(exifData).not.toHaveProperty('GPSLongitude'); + expect(exifData).not.toHaveProperty('GPSLatitude'); + }); + }); + + describe('GET /asset/file/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get( + `/asset/thumbnail/${assetLocation.id}`, + ); + + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should download the original', async () => { + const { status, body, type } = await request(app) + .get(`/asset/file/${assetLocation.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toBeDefined(); + expect(type).toBe('image/jpeg'); + + const asset = await apiUtils.getAssetInfo( + admin.accessToken, + assetLocation.id, + ); + + const original = await readFile(locationAssetFilepath); + const originalChecksum = sha1(original); + const downloadChecksum = sha1(body); + + expect(originalChecksum).toBe(downloadChecksum); + expect(downloadChecksum).toBe(asset.checksum); + }); + }); }); diff --git a/e2e/src/api/specs/audit.e2e-spec.ts b/e2e/src/api/specs/audit.e2e-spec.ts index 073106e728..0bc8e6b173 100644 --- a/e2e/src/api/specs/audit.e2e-spec.ts +++ b/e2e/src/api/specs/audit.e2e-spec.ts @@ -29,14 +29,14 @@ describe('/audit', () => { await Promise.all([ deleteAssets( { assetBulkDeleteDto: { ids: [trashedAsset.id] } }, - { headers: asBearerAuth(admin.accessToken) } + { headers: asBearerAuth(admin.accessToken) }, ), updateAsset( { id: archivedAsset.id, updateAssetDto: { isArchived: true }, }, - { headers: asBearerAuth(admin.accessToken) } + { headers: asBearerAuth(admin.accessToken) }, ), ]); diff --git a/e2e/src/api/specs/trash.e2e-spec.ts b/e2e/src/api/specs/trash.e2e-spec.ts index 2de838f981..cb4a8b9dd1 100644 --- a/e2e/src/api/specs/trash.e2e-spec.ts +++ b/e2e/src/api/specs/trash.e2e-spec.ts @@ -44,7 +44,7 @@ describe('/trash', () => { .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(204); - await wsUtils.once(ws, 'on_asset_delete'); + await wsUtils.waitForEvent({ event: 'delete', assetId }); const after = await getAllAssets( {}, diff --git a/e2e/src/setup.ts b/e2e/src/setup.ts index b560a2bbb1..04e8d79ac5 100644 --- a/e2e/src/setup.ts +++ b/e2e/src/setup.ts @@ -2,25 +2,25 @@ import { spawn, exec } from 'child_process'; export default async () => { let _resolve: () => unknown; - const promise = new Promise((resolve) => (_resolve = resolve)); + const ready = new Promise((resolve) => (_resolve = resolve)); const child = spawn('docker', ['compose', 'up'], { stdio: 'pipe' }); child.stdout.on('data', (data) => { const input = data.toString(); console.log(input); - if (input.includes('Immich Server is listening')) { + if (input.includes('Immich Microservices is listening')) { _resolve(); } }); child.stderr.on('data', (data) => console.log(data.toString())); - await promise; + await ready; return async () => { await new Promise((resolve) => - exec('docker compose down', () => resolve()) + exec('docker compose down', () => resolve()), ); }; }; diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 428c88b454..4261e8f67d 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -1,5 +1,6 @@ import { AssetFileUploadResponseDto, + AssetResponseDto, CreateAlbumDto, CreateAssetDto, CreateUserDto, @@ -19,10 +20,12 @@ import { updatePerson, } from '@immich/sdk'; import { BrowserContext } from '@playwright/test'; -import { exec, spawn } from 'child_process'; +import { exec, spawn } from 'node:child_process'; import { randomBytes } from 'node:crypto'; import { access } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; import path from 'node:path'; +import { EventEmitter } from 'node:stream'; import { promisify } from 'node:util'; import pg from 'pg'; import { io, type Socket } from 'socket.io-client'; @@ -40,6 +43,7 @@ const directoryExists = (directory: string) => // TODO move test assets into e2e/assets export const testAssetDir = path.resolve(`./../server/test/assets/`); +export const tempDir = tmpdir(); const serverContainerName = 'immich-e2e-server'; const mediaDir = '/usr/src/app/upload'; @@ -47,6 +51,7 @@ const dirs = [ `"${mediaDir}/thumbs"`, `"${mediaDir}/upload"`, `"${mediaDir}/library"`, + `"${mediaDir}/encoded-video"`, ].join(' '); if (!(await directoryExists(`${testAssetDir}/albums`))) { @@ -177,33 +182,85 @@ export interface AdminSetupOptions { onboarding?: boolean; } +export enum SocketEvent { + UPLOAD = 'upload', + DELETE = 'delete', +} + +export type EventType = 'upload' | 'delete'; +export interface WaitOptions { + event: EventType; + assetId: string; + timeout?: number; +} + +const events: Record> = { + upload: new Set(), + delete: new Set(), +}; + +const callbacks: Record void> = {}; + +const onEvent = ({ event, assetId }: { event: EventType; assetId: string }) => { + events[event].add(assetId); + const callback = callbacks[assetId]; + if (callback) { + callback(); + delete callbacks[assetId]; + } +}; + export const wsUtils = { connect: async (accessToken: string) => { const websocket = io('http://127.0.0.1:2283', { path: '/api/socket.io', transports: ['websocket'], extraHeaders: { Authorization: `Bearer ${accessToken}` }, - autoConnect: false, + autoConnect: true, forceNew: true, }); return new Promise((resolve) => { - websocket.on('connect', () => resolve(websocket)); - websocket.connect(); + websocket + .on('connect', () => resolve(websocket)) + .on('on_upload_success', (data: AssetResponseDto) => + onEvent({ event: 'upload', assetId: data.id }), + ) + .on('on_asset_delete', (assetId: string) => + onEvent({ event: 'delete', assetId }), + ) + .connect(); }); }, disconnect: (ws: Socket) => { if (ws?.connected) { ws.disconnect(); } + + for (const set of Object.values(events)) { + set.clear(); + } }, - once: (ws: Socket, event: string): Promise => { - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => reject(new Error('Timeout')), 4000); - ws.once(event, (data: T) => { + waitForEvent: async ({ + event, + assetId, + timeout: ms, + }: WaitOptions): Promise => { + const set = events[event]; + if (set.has(assetId)) { + return; + } + + return new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error(`Timed out waiting for ${event} event`)), + ms || 5000, + ); + + callbacks[assetId] = () => { clearTimeout(timeout); - resolve(data); - }); + resolve(); + }; }); }, }; diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index 3c8c7006e7..f2d11a7d29 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.96.0" +version = "1.97.0" description = "" authors = ["Hau Tran "] readme = "README.md" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index 9b7587587e..6e051103d1 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -35,8 +35,8 @@ platform :android do task: 'bundle', build_type: 'Release', properties: { - "android.injected.version.code" => 124, - "android.injected.version.name" => "1.96.0", + "android.injected.version.code" => 125, + "android.injected.version.name" => "1.97.0", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/android/fastlane/report.xml b/mobile/android/fastlane/report.xml index 585549fdb6..c5442f6cdc 100644 --- a/mobile/android/fastlane/report.xml +++ b/mobile/android/fastlane/report.xml @@ -5,17 +5,17 @@ - + - + - + diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index e9c5dda27e..daa6bff38f 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -379,7 +379,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 139; + CURRENT_PROJECT_VERSION = 140; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -515,7 +515,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 139; + CURRENT_PROJECT_VERSION = 140; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -543,7 +543,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 139; + CURRENT_PROJECT_VERSION = 140; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 30ec0055c2..e4356050c5 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -55,11 +55,11 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.95.0 + 1.96.0 CFBundleSignature ???? CFBundleVersion - 139 + 140 FLTEnableImpeller ITSAppUsesNonExemptEncryption diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index 939689c68d..caf1dd8e68 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Beta" lane :beta do increment_version_number( - version_number: "1.96.0" + version_number: "1.97.0" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/ios/fastlane/report.xml b/mobile/ios/fastlane/report.xml index 7e27b4085d..dc478d8ee3 100644 --- a/mobile/ios/fastlane/report.xml +++ b/mobile/ios/fastlane/report.xml @@ -5,32 +5,32 @@ - + - + - + - + - + - + diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart index 48eb778c10..dfdfb32844 100644 --- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart +++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'dart:math'; import 'dart:ui' as ui; @@ -132,7 +133,7 @@ class GalleryViewerPage extends HookConsumerWidget { void toggleFavorite(Asset asset) => ref.read(assetProvider.notifier).toggleFavorite([asset]); - void precacheNextImage(int index) { + Future precacheNextImage(int index) async { void onError(Object exception, StackTrace? stackTrace) { // swallow error silently debugPrint('Error precaching next image: $exception, $stackTrace'); @@ -140,7 +141,7 @@ class GalleryViewerPage extends HookConsumerWidget { if (index < totalAssets && index >= 0) { final asset = loadAsset(index); - precacheImage( + await precacheImage( ImmichImage.imageProvider(asset: asset), context, onError: onError, @@ -711,6 +712,21 @@ class GalleryViewerPage extends HookConsumerWidget { [], ); + useEffect( + () { + // No need to await this + unawaited( + // Delay this a bit so we can finish loading the page + Future.delayed(const Duration(milliseconds: 400)).then( + // Precache the next image + (_) => precacheNextImage(currentIndex.value + 1), + ), + ); + return null; + }, + [], + ); + ref.listen(showControlsProvider, (_, show) { if (show) { SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); @@ -735,14 +751,21 @@ class GalleryViewerPage extends HookConsumerWidget { isZoomed.value = state != PhotoViewScaleState.initial; ref.read(showControlsProvider.notifier).show = !isZoomed.value; }, - loadingBuilder: (context, event, index) => ImageFiltered( - imageFilter: ui.ImageFilter.blur( - sigmaX: 1, - sigmaY: 1, - ), - child: ImmichThumbnail( - asset: asset(), - fit: BoxFit.contain, + loadingBuilder: (context, event, index) => ClipRect( + child: Stack( + fit: StackFit.expand, + children: [ + BackdropFilter( + filter: ui.ImageFilter.blur( + sigmaX: 10, + sigmaY: 10, + ), + ), + ImmichThumbnail( + asset: asset(), + fit: BoxFit.contain, + ), + ], ), ), pageController: controller, @@ -754,12 +777,16 @@ class GalleryViewerPage extends HookConsumerWidget { ), itemCount: totalAssets, scrollDirection: Axis.horizontal, - onPageChanged: (value) { + onPageChanged: (value) async { final next = currentIndex.value < value ? value + 1 : value - 1; - precacheNextImage(next); + HapticFeedback.selectionClick(); currentIndex.value = value; stackIndex.value = -1; - HapticFeedback.selectionClick(); + + // Wait for page change animation to finish + await Future.delayed(const Duration(milliseconds: 400)); + // Then precache the next image + unawaited(precacheNextImage(next)); }, builder: (context, index) { final a = @@ -818,7 +845,7 @@ class GalleryViewerPage extends HookConsumerWidget { isMotionVideo: isPlayingMotionVideo.value, placeholder: Image( image: provider, - fit: BoxFit.fitWidth, + fit: BoxFit.contain, height: context.height, width: context.width, alignment: Alignment.center, diff --git a/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart b/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart index eb125f27fb..0da2bc52db 100644 --- a/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart +++ b/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart @@ -40,7 +40,7 @@ class VideoViewerPage extends HookWidget { controlsSafeAreaMinimum: const EdgeInsets.only( bottom: 100, ), - placeholder: SizedBox.expand(child: placeholder), + placeholder: placeholder, showControls: showControls && !isMotionVideo, hideControlsTimer: hideControlsTimer, customControls: const VideoPlayerControls(), @@ -58,9 +58,13 @@ class VideoViewerPage extends HookWidget { if (controller == null) { return Stack( children: [ - if (placeholder != null) SizedBox.expand(child: placeholder!), - const DelayedLoadingIndicator( - fadeInDuration: Duration(milliseconds: 500), + if (placeholder != null) placeholder!, + const Positioned.fill( + child: Center( + child: DelayedLoadingIndicator( + fadeInDuration: Duration(milliseconds: 500), + ), + ), ), ], ); diff --git a/mobile/lib/modules/memories/views/memory_page.dart b/mobile/lib/modules/memories/views/memory_page.dart index 9308e812dc..d06bb959ee 100644 --- a/mobile/lib/modules/memories/views/memory_page.dart +++ b/mobile/lib/modules/memories/views/memory_page.dart @@ -124,11 +124,14 @@ class MemoryPage extends HookConsumerWidget { .then((_) => precacheAsset(1)); } - onAssetChanged(int otherIndex) { + Future onAssetChanged(int otherIndex) async { HapticFeedback.selectionClick(); currentAssetPage.value = otherIndex; - precacheAsset(otherIndex + 1); updateProgressText(); + // Wait for page change animation to finish + await Future.delayed(const Duration(milliseconds: 400)); + // And then precache the next asset + await precacheAsset(otherIndex + 1); } /* Notification listener is used instead of OnPageChanged callback since OnPageChanged is called diff --git a/mobile/lib/shared/ui/delayed_loading_indicator.dart b/mobile/lib/shared/ui/delayed_loading_indicator.dart index b4d9f4c806..e009b660c9 100644 --- a/mobile/lib/shared/ui/delayed_loading_indicator.dart +++ b/mobile/lib/shared/ui/delayed_loading_indicator.dart @@ -20,21 +20,24 @@ class DelayedLoadingIndicator extends StatelessWidget { @override Widget build(BuildContext context) { - return AnimatedSwitcher( - duration: fadeInDuration ?? Duration.zero, - child: FutureBuilder( - future: Future.delayed(delay), - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.done) { - return child ?? - const ImmichLoadingIndicator( - key: ValueKey('loading'), - ); - } + return FutureBuilder( + future: Future.delayed(delay), + builder: (context, snapshot) { + late Widget c; + if (snapshot.connectionState == ConnectionState.done) { + c = child ?? + const ImmichLoadingIndicator( + key: ValueKey('loading'), + ); + } else { + c = Container(key: const ValueKey('hiding')); + } - return Container(key: const ValueKey('hiding')); - }, - ), + return AnimatedSwitcher( + duration: fadeInDuration ?? Duration.zero, + child: c, + ); + }, ); } } diff --git a/mobile/lib/shared/ui/immich_image.dart b/mobile/lib/shared/ui/immich_image.dart index 3137f63014..f06f1726a1 100644 --- a/mobile/lib/shared/ui/immich_image.dart +++ b/mobile/lib/shared/ui/immich_image.dart @@ -58,9 +58,11 @@ class ImmichImage extends StatelessWidget { } } + // Whether to use the local asset image provider or a remote one static bool useLocal(Asset asset) => !asset.isRemote || asset.isLocal && !Store.get(StoreKey.preferRemoteImage, false); + @override Widget build(BuildContext context) { if (asset == null) { diff --git a/mobile/lib/shared/ui/immich_thumbnail.dart b/mobile/lib/shared/ui/immich_thumbnail.dart index fe35bdaac2..77827348db 100644 --- a/mobile/lib/shared/ui/immich_thumbnail.dart +++ b/mobile/lib/shared/ui/immich_thumbnail.dart @@ -6,6 +6,7 @@ import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_local_ import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_image_provider.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/ui/hooks/blurhash_hook.dart'; +import 'package:immich_mobile/shared/ui/immich_image.dart'; import 'package:immich_mobile/shared/ui/thumbhash_placeholder.dart'; import 'package:octo_image/octo_image.dart'; @@ -43,7 +44,7 @@ class ImmichThumbnail extends HookWidget { ); } - if (useLocal(asset)) { + if (ImmichImage.useLocal(asset)) { return ImmichLocalThumbnailProvider( asset: asset, height: thumbnailSize, @@ -57,8 +58,6 @@ class ImmichThumbnail extends HookWidget { } } - static bool useLocal(Asset asset) => !asset.isRemote || asset.isLocal; - @override Widget build(BuildContext context) { Uint8List? blurhash = useBlurHashRef(asset).value; diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index c7526fc940..b8548c79e6 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -3,7 +3,7 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: -- API version: 1.96.0 +- API version: 1.97.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen ## Requirements diff --git a/mobile/openapi/doc/SystemConfigLibraryWatchDto.md b/mobile/openapi/doc/SystemConfigLibraryWatchDto.md index baa0be6b08..43b975e21a 100644 --- a/mobile/openapi/doc/SystemConfigLibraryWatchDto.md +++ b/mobile/openapi/doc/SystemConfigLibraryWatchDto.md @@ -9,8 +9,6 @@ import 'package:openapi/api.dart'; Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- **enabled** | **bool** | | -**interval** | **int** | | -**usePolling** | **bool** | | [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/mobile/openapi/lib/model/system_config_library_watch_dto.dart b/mobile/openapi/lib/model/system_config_library_watch_dto.dart index 795fd15fd3..0bcb6f1771 100644 --- a/mobile/openapi/lib/model/system_config_library_watch_dto.dart +++ b/mobile/openapi/lib/model/system_config_library_watch_dto.dart @@ -14,37 +14,25 @@ class SystemConfigLibraryWatchDto { /// Returns a new [SystemConfigLibraryWatchDto] instance. SystemConfigLibraryWatchDto({ required this.enabled, - required this.interval, - required this.usePolling, }); bool enabled; - int interval; - - bool usePolling; - @override bool operator ==(Object other) => identical(this, other) || other is SystemConfigLibraryWatchDto && - other.enabled == enabled && - other.interval == interval && - other.usePolling == usePolling; + other.enabled == enabled; @override int get hashCode => // ignore: unnecessary_parenthesis - (enabled.hashCode) + - (interval.hashCode) + - (usePolling.hashCode); + (enabled.hashCode); @override - String toString() => 'SystemConfigLibraryWatchDto[enabled=$enabled, interval=$interval, usePolling=$usePolling]'; + String toString() => 'SystemConfigLibraryWatchDto[enabled=$enabled]'; Map toJson() { final json = {}; json[r'enabled'] = this.enabled; - json[r'interval'] = this.interval; - json[r'usePolling'] = this.usePolling; return json; } @@ -57,8 +45,6 @@ class SystemConfigLibraryWatchDto { return SystemConfigLibraryWatchDto( enabled: mapValueOfType(json, r'enabled')!, - interval: mapValueOfType(json, r'interval')!, - usePolling: mapValueOfType(json, r'usePolling')!, ); } return null; @@ -107,8 +93,6 @@ class SystemConfigLibraryWatchDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { 'enabled', - 'interval', - 'usePolling', }; } diff --git a/mobile/openapi/test/system_config_library_watch_dto_test.dart b/mobile/openapi/test/system_config_library_watch_dto_test.dart index 74ab53d652..19b5de05fe 100644 --- a/mobile/openapi/test/system_config_library_watch_dto_test.dart +++ b/mobile/openapi/test/system_config_library_watch_dto_test.dart @@ -21,16 +21,6 @@ void main() { // TODO }); - // int interval - test('to test the property `interval`', () async { - // TODO - }); - - // bool usePolling - test('to test the property `usePolling`', () async { - // TODO - }); - }); diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 9e379d4653..f27351898d 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -413,10 +413,10 @@ packages: dependency: transitive description: name: file - sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" + sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d" url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "6.1.4" file_selector_linux: dependency: transitive description: @@ -860,30 +860,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.8.1" - leak_tracker: - dependency: transitive - description: - name: leak_tracker - sha256: "78eb209deea09858f5269f5a5b02be4049535f568c07b275096836f01ea323fa" - url: "https://pub.dev" - source: hosted - version: "10.0.0" - leak_tracker_flutter_testing: - dependency: transitive - description: - name: leak_tracker_flutter_testing - sha256: b46c5e37c19120a8a01918cfaf293547f47269f7cb4b0058f21531c2465d6ef0 - url: "https://pub.dev" - source: hosted - version: "2.0.1" - leak_tracker_testing: - dependency: transitive - description: - name: leak_tracker_testing - sha256: a597f72a664dbd293f3bfc51f9ba69816f84dcd403cdac7066cb3f6003f3ab47 - url: "https://pub.dev" - source: hosted - version: "2.0.1" lints: dependency: transitive description: @@ -931,18 +907,18 @@ packages: dependency: transitive description: name: matcher - sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" url: "https://pub.dev" source: hosted - version: "0.12.16+1" + version: "0.12.16" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41" url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "0.5.0" meta: dependency: "direct overridden" description: @@ -1026,10 +1002,10 @@ packages: dependency: "direct main" description: name: path - sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917" url: "https://pub.dev" source: hosted - version: "1.9.0" + version: "1.8.3" path_provider: dependency: "direct main" description: @@ -1162,10 +1138,10 @@ packages: dependency: transitive description: name: platform - sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" + sha256: ae68c7bfcd7383af3629daafb32fb4e8681c7154428da4febcff06200585f102 url: "https://pub.dev" source: hosted - version: "3.1.4" + version: "3.1.2" plugin_platform_interface: dependency: transitive description: @@ -1194,10 +1170,10 @@ packages: dependency: transitive description: name: process - sha256: "21e54fd2faf1b5bdd5102afd25012184a6793927648ea81eea80552ac9405b32" + sha256: "53fd8db9cec1d37b0574e12f07520d582019cb6c44abf5479a01505099a34a09" url: "https://pub.dev" source: hosted - version: "5.0.2" + version: "4.2.4" provider: dependency: transitive description: @@ -1663,10 +1639,10 @@ packages: dependency: transitive description: name: vm_service - sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957 + sha256: c538be99af830f478718b51630ec1b6bee5e74e52c8a802d328d9e71d35d2583 url: "https://pub.dev" source: hosted - version: "13.0.0" + version: "11.10.0" wakelock_plus: dependency: "direct main" description: @@ -1711,10 +1687,10 @@ packages: dependency: transitive description: name: webdriver - sha256: "003d7da9519e1e5f329422b36c4dcdf18d7d2978d1ba099ea4e45ba490ed845e" + sha256: "3c923e918918feeb90c4c9fdf1fe39220fa4c0e8e2c0fffaded174498ef86c49" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.2" win32: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index d9ecc90c4b..04056977a4 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: "none" -version: 1.96.0+124 +version: 1.97.0+125 isar_version: &isar_version 3.1.0+1 environment: diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index f94435e567..3092c6cc63 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -6467,7 +6467,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.96.0", + "version": "1.97.0", "contact": {} }, "tags": [], @@ -9835,18 +9835,10 @@ "properties": { "enabled": { "type": "boolean" - }, - "interval": { - "type": "integer" - }, - "usePolling": { - "type": "boolean" } }, "required": [ - "enabled", - "interval", - "usePolling" + "enabled" ], "type": "object" }, diff --git a/open-api/typescript-sdk/axios-client/api.ts b/open-api/typescript-sdk/axios-client/api.ts index 3cca34319c..40a9da60ab 100644 --- a/open-api/typescript-sdk/axios-client/api.ts +++ b/open-api/typescript-sdk/axios-client/api.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.96.0 + * The version of the OpenAPI document: 1.97.0 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). @@ -4395,18 +4395,6 @@ export interface SystemConfigLibraryWatchDto { * @memberof SystemConfigLibraryWatchDto */ 'enabled': boolean; - /** - * - * @type {number} - * @memberof SystemConfigLibraryWatchDto - */ - 'interval': number; - /** - * - * @type {boolean} - * @memberof SystemConfigLibraryWatchDto - */ - 'usePolling': boolean; } /** * diff --git a/open-api/typescript-sdk/axios-client/base.ts b/open-api/typescript-sdk/axios-client/base.ts index bc29eda029..43fa9de47c 100644 --- a/open-api/typescript-sdk/axios-client/base.ts +++ b/open-api/typescript-sdk/axios-client/base.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.96.0 + * The version of the OpenAPI document: 1.97.0 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/open-api/typescript-sdk/axios-client/common.ts b/open-api/typescript-sdk/axios-client/common.ts index 4fb512c0fb..8257bfe6f9 100644 --- a/open-api/typescript-sdk/axios-client/common.ts +++ b/open-api/typescript-sdk/axios-client/common.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.96.0 + * The version of the OpenAPI document: 1.97.0 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/open-api/typescript-sdk/axios-client/configuration.ts b/open-api/typescript-sdk/axios-client/configuration.ts index 6f2bacbbc6..18dc661330 100644 --- a/open-api/typescript-sdk/axios-client/configuration.ts +++ b/open-api/typescript-sdk/axios-client/configuration.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.96.0 + * The version of the OpenAPI document: 1.97.0 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/open-api/typescript-sdk/axios-client/index.ts b/open-api/typescript-sdk/axios-client/index.ts index cf9819f950..6c53769b74 100644 --- a/open-api/typescript-sdk/axios-client/index.ts +++ b/open-api/typescript-sdk/axios-client/index.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.96.0 + * The version of the OpenAPI document: 1.97.0 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/open-api/typescript-sdk/fetch-client.ts b/open-api/typescript-sdk/fetch-client.ts index c2d55072d2..ce768d518b 100644 --- a/open-api/typescript-sdk/fetch-client.ts +++ b/open-api/typescript-sdk/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 1.96.0 + * 1.97.0 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ @@ -834,8 +834,6 @@ export type SystemConfigLibraryScanDto = { }; export type SystemConfigLibraryWatchDto = { enabled: boolean; - interval: number; - usePolling: boolean; }; export type SystemConfigLibraryDto = { scan: SystemConfigLibraryScanDto; diff --git a/server/e2e/jobs/specs/formats.e2e-spec.ts b/server/e2e/jobs/specs/formats.e2e-spec.ts deleted file mode 100644 index c8b14d588a..0000000000 --- a/server/e2e/jobs/specs/formats.e2e-spec.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { LoginResponseDto } from '@app/domain'; -import { AssetType } from '@app/infra/entities'; -import { readFile } from 'node:fs/promises'; -import { basename, join } from 'node:path'; -import { IMMICH_TEST_ASSET_PATH, testApp } from '../../../src/test-utils/utils'; -import { api } from '../../client'; - -const JPEG = { - type: AssetType.IMAGE, - originalFileName: 'el_torcal_rocks', - resized: true, - exifInfo: { - dateTimeOriginal: '2012-08-05T11:39:59.000Z', - exifImageWidth: 512, - exifImageHeight: 341, - latitude: null, - longitude: null, - focalLength: 75, - iso: 200, - fNumber: 11, - exposureTime: '1/160', - fileSizeInByte: 53_493, - make: 'SONY', - model: 'DSLR-A550', - orientation: null, - description: 'SONY DSC', - }, -}; - -const tests = [ - { input: 'formats/jpg/el_torcal_rocks.jpg', expected: JPEG }, - { input: 'formats/jpeg/el_torcal_rocks.jpeg', expected: JPEG }, - { - input: 'formats/heic/IMG_2682.heic', - expected: { - type: AssetType.IMAGE, - originalFileName: 'IMG_2682', - resized: true, - fileCreatedAt: '2019-03-21T16:04:22.348Z', - exifInfo: { - dateTimeOriginal: '2019-03-21T16:04:22.348Z', - exifImageWidth: 4032, - exifImageHeight: 3024, - latitude: 41.2203, - longitude: -96.071_625, - make: 'Apple', - model: 'iPhone 7', - lensModel: 'iPhone 7 back camera 3.99mm f/1.8', - fileSizeInByte: 880_703, - exposureTime: '1/887', - iso: 20, - focalLength: 3.99, - fNumber: 1.8, - timeZone: 'America/Chicago', - }, - }, - }, - { - input: 'formats/png/density_plot.png', - expected: { - type: AssetType.IMAGE, - originalFileName: 'density_plot', - resized: true, - exifInfo: { - exifImageWidth: 800, - exifImageHeight: 800, - latitude: null, - longitude: null, - fileSizeInByte: 25_408, - }, - }, - }, - { - input: 'formats/raw/Nikon/D80/glarus.nef', - expected: { - type: AssetType.IMAGE, - originalFileName: 'glarus', - resized: true, - fileCreatedAt: '2010-07-20T17:27:12.000Z', - exifInfo: { - make: 'NIKON CORPORATION', - model: 'NIKON D80', - exposureTime: '1/200', - fNumber: 10, - focalLength: 18, - iso: 100, - fileSizeInByte: 9_057_784, - dateTimeOriginal: '2010-07-20T17:27:12.000Z', - latitude: null, - longitude: null, - orientation: '1', - }, - }, - }, - { - input: 'formats/raw/Nikon/D700/philadelphia.nef', - expected: { - type: AssetType.IMAGE, - originalFileName: 'philadelphia', - resized: true, - fileCreatedAt: '2016-09-22T22:10:29.060Z', - exifInfo: { - make: 'NIKON CORPORATION', - model: 'NIKON D700', - exposureTime: '1/400', - fNumber: 11, - focalLength: 85, - iso: 200, - fileSizeInByte: 15_856_335, - dateTimeOriginal: '2016-09-22T22:10:29.060Z', - latitude: null, - longitude: null, - orientation: '1', - timeZone: 'UTC-5', - }, - }, - }, -]; - -describe(`Format (e2e)`, () => { - let server: any; - let admin: LoginResponseDto; - - beforeAll(async () => { - const app = await testApp.create(); - server = app.getHttpServer(); - }); - - beforeEach(async () => { - await testApp.reset(); - await api.authApi.adminSignUp(server); - admin = await api.authApi.adminLogin(server); - }); - - afterAll(async () => { - await testApp.teardown(); - }); - - for (const { input, expected } of tests) { - it(`should generate a thumbnail for ${input}`, async () => { - const filepath = join(IMMICH_TEST_ASSET_PATH, input); - const content = await readFile(filepath); - await api.assetApi.upload(server, admin.accessToken, 'test-device-id', { - content, - filename: basename(filepath), - }); - const assets = await api.assetApi.getAllAssets(server, admin.accessToken); - - expect(assets).toHaveLength(1); - - const asset = assets[0]; - - expect(asset.exifInfo).toBeDefined(); - expect(asset.exifInfo).toMatchObject(expected.exifInfo); - expect(asset).toMatchObject(expected); - }); - } -}); diff --git a/server/e2e/jobs/specs/metadata.e2e-spec.ts b/server/e2e/jobs/specs/metadata.e2e-spec.ts deleted file mode 100644 index 5eb75fee2d..0000000000 --- a/server/e2e/jobs/specs/metadata.e2e-spec.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { AssetResponseDto, LoginResponseDto } from '@app/domain'; -import { AssetController } from '@app/immich'; -import { exiftool } from 'exiftool-vendored'; -import { readFile, writeFile } from 'fs/promises'; -import { - IMMICH_TEST_ASSET_PATH, - IMMICH_TEST_ASSET_TEMP_PATH, - db, - restoreTempFolder, - testApp, -} from '../../../src/test-utils/utils'; -import { api } from '../../client'; - -describe(`${AssetController.name} (e2e)`, () => { - let server: any; - let admin: LoginResponseDto; - - beforeAll(async () => { - server = (await testApp.create()).getHttpServer(); - }); - - beforeEach(async () => { - await testApp.reset(); - await restoreTempFolder(); - await api.authApi.adminSignUp(server); - admin = await api.authApi.adminLogin(server); - }); - - afterAll(async () => { - await testApp.teardown(); - await restoreTempFolder(); - }); - - describe('should strip metadata of', () => { - let assetWithLocation: AssetResponseDto; - - beforeEach(async () => { - const fileContent = await readFile(`${IMMICH_TEST_ASSET_PATH}/metadata/gps-position/thompson-springs.jpg`); - - await api.assetApi.upload(server, admin.accessToken, 'test-asset-id', { content: fileContent }); - - const assets = await api.assetApi.getAllAssets(server, admin.accessToken); - - expect(assets).toHaveLength(1); - assetWithLocation = assets[0]; - - expect(assetWithLocation).toEqual( - expect.objectContaining({ - exifInfo: expect.objectContaining({ latitude: 39.115, longitude: -108.400968333333 }), - }), - ); - }); - - it('small webp thumbnails', async () => { - const assetId = assetWithLocation.id; - - const thumbnail = await api.assetApi.getWebpThumbnail(server, admin.accessToken, assetId); - - await writeFile(`${IMMICH_TEST_ASSET_TEMP_PATH}/thumbnail.webp`, thumbnail); - - const exifData = await exiftool.read(`${IMMICH_TEST_ASSET_TEMP_PATH}/thumbnail.webp`); - - expect(exifData).not.toHaveProperty('GPSLongitude'); - expect(exifData).not.toHaveProperty('GPSLatitude'); - }); - - it('large jpeg thumbnails', async () => { - const assetId = assetWithLocation.id; - - const thumbnail = await api.assetApi.getJpegThumbnail(server, admin.accessToken, assetId); - - await writeFile(`${IMMICH_TEST_ASSET_TEMP_PATH}/thumbnail.jpg`, thumbnail); - - const exifData = await exiftool.read(`${IMMICH_TEST_ASSET_TEMP_PATH}/thumbnail.jpg`); - - expect(exifData).not.toHaveProperty('GPSLongitude'); - expect(exifData).not.toHaveProperty('GPSLatitude'); - }); - }); - - describe.each([ - // These hashes were created by copying the image files to a Samsung phone, - // exporting the video from Samsung's stock Gallery app, and hashing them locally. - // This ensures that immich+exiftool are extracting the videos the same way Samsung does. - // DO NOT assume immich+exiftool are doing things correctly and just copy whatever hash it gives - // into the test here. - ['Samsung One UI 5.jpg', 'fr14niqCq6N20HB8rJYEvpsUVtI='], - ['Samsung One UI 6.jpg', 'lT9Uviw/FFJYCjfIxAGPTjzAmmw='], - ['Samsung One UI 6.heic', '/ejgzywvgvzvVhUYVfvkLzFBAF0='], - ])('should extract motionphoto video', (file, checksum) => { - it(`with checksum ${checksum} from ${file}`, async () => { - const fileContent = await readFile(`${IMMICH_TEST_ASSET_PATH}/formats/motionphoto/${file}`); - - const response = await api.assetApi.upload(server, admin.accessToken, 'test-asset-id', { content: fileContent }); - const asset = await api.assetApi.get(server, admin.accessToken, response.id); - expect(asset).toHaveProperty('livePhotoVideoId'); - const video = await api.assetApi.get(server, admin.accessToken, asset.livePhotoVideoId as string); - - expect(video.checksum).toStrictEqual(checksum); - }); - }); -}); diff --git a/server/package-lock.json b/server/package-lock.json index b9d28a9155..4ba12e2636 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.96.0", + "version": "1.97.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "immich", - "version": "1.96.0", + "version": "1.97.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@babel/runtime": "^7.22.11", diff --git a/server/package.json b/server/package.json index 29ce96464e..d09854dede 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.96.0", + "version": "1.97.0", "description": "", "author": "", "private": true, diff --git a/server/src/domain/library/library.service.ts b/server/src/domain/library/library.service.ts index 8f42bddfdd..4d89126859 100644 --- a/server/src/domain/library/library.service.ts +++ b/server/src/domain/library/library.service.ts @@ -113,20 +113,13 @@ export class LibraryService extends EventEmitter { ignore: library.exclusionPatterns, }); - const config = await this.configCore.getConfig(); - const { usePolling, interval } = config.library.watch; - - this.logger.debug(`Settings for watcher: usePolling: ${usePolling}, interval: ${interval}`); - let _resolve: () => void; const ready$ = new Promise((resolve) => (_resolve = resolve)); this.watchers[id] = this.storageRepository.watch( library.importPaths, { - usePolling, - interval, - binaryInterval: interval, + usePolling: false, ignoreInitial: true, }, { diff --git a/server/src/domain/media/media.service.spec.ts b/server/src/domain/media/media.service.spec.ts index dc5934e7b1..0944016379 100644 --- a/server/src/domain/media/media.service.spec.ts +++ b/server/src/domain/media/media.service.spec.ts @@ -1799,7 +1799,7 @@ describe(MediaService.name, () => { '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', { - inputOptions: [], + inputOptions: ['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga'], outputOptions: [ `-c:v hevc_rkmpp`, '-c:a copy', @@ -1810,9 +1810,9 @@ describe(MediaService.name, () => { '-g 256', '-tag:v hvc1', '-v verbose', - '-vf scale=-2:720,format=yuv420p', + '-vf scale_rkrga=-2:720:format=nv12:afbc=1', '-level 153', - '-rc_mode 3', + '-rc_mode AVBR', '-b:v 10000k', ], twoPass: false, @@ -1834,7 +1834,7 @@ describe(MediaService.name, () => { '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', { - inputOptions: [], + inputOptions: ['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga'], outputOptions: [ `-c:v h264_rkmpp`, '-c:a copy', @@ -1844,9 +1844,9 @@ describe(MediaService.name, () => { '-map 0:1', '-g 256', '-v verbose', - '-vf scale=-2:720,format=yuv420p', + '-vf scale_rkrga=-2:720:format=nv12:afbc=1', '-level 51', - '-rc_mode 2', + '-rc_mode CQP', '-qp_init 30', ], twoPass: false, diff --git a/server/src/domain/media/media.util.ts b/server/src/domain/media/media.util.ts index e5890bdd03..d5f08ab0de 100644 --- a/server/src/domain/media/media.util.ts +++ b/server/src/domain/media/media.util.ts @@ -14,7 +14,7 @@ class BaseConfig implements VideoCodecSWConfig { getOptions(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) { const options = { - inputOptions: this.getBaseInputOptions(), + inputOptions: this.getBaseInputOptions(videoStream), outputOptions: [...this.getBaseOutputOptions(target, videoStream, audioStream), '-v verbose'], twoPass: this.eligibleForTwoPass(), } as TranscodeOptions; @@ -30,7 +30,8 @@ class BaseConfig implements VideoCodecSWConfig { return options; } - getBaseInputOptions(): string[] { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getBaseInputOptions(videoStream: VideoStreamInfo): string[] { return []; } @@ -611,10 +612,25 @@ export class RKMPPConfig extends BaseHWConfig { return false; } - getBaseInputOptions() { + getBaseInputOptions(videoStream: VideoStreamInfo) { if (this.devices.length === 0) { throw new Error('No RKMPP device found'); } + if (this.shouldToneMap(videoStream)) { + // disable hardware decoding + return []; + } + return ['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga']; + } + + getFilterOptions(videoStream: VideoStreamInfo) { + if (this.shouldToneMap(videoStream)) { + // use software filter options + return super.getFilterOptions(videoStream); + } + if (this.shouldScale(videoStream)) { + return [`scale_rkrga=${this.getScaling(videoStream)}:format=nv12:afbc=1`]; + } return []; } @@ -638,10 +654,10 @@ export class RKMPPConfig extends BaseHWConfig { const bitrate = this.getMaxBitrateValue(); if (bitrate > 0) { // -b:v specifies max bitrate, average bitrate is derived automatically... - return ['-rc_mode 3', `-b:v ${bitrate}${this.getBitrateUnit()}`]; + return ['-rc_mode AVBR', `-b:v ${bitrate}${this.getBitrateUnit()}`]; } // use CRF value as QP value - return ['-rc_mode 2', `-qp_init ${this.config.crf}`]; + return ['-rc_mode CQP', `-qp_init ${this.config.crf}`]; } getSupportedCodecs() { diff --git a/server/src/domain/system-config/dto/system-config-library.dto.ts b/server/src/domain/system-config/dto/system-config-library.dto.ts index caf73498f2..fdbae600f6 100644 --- a/server/src/domain/system-config/dto/system-config-library.dto.ts +++ b/server/src/domain/system-config/dto/system-config-library.dto.ts @@ -1,12 +1,9 @@ import { validateCronExpression } from '@app/domain'; -import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsBoolean, - IsInt, IsNotEmpty, IsObject, - IsPositive, IsString, Validate, ValidateIf, @@ -38,14 +35,6 @@ export class SystemConfigLibraryScanDto { export class SystemConfigLibraryWatchDto { @IsBoolean() enabled!: boolean; - - @IsBoolean() - usePolling!: boolean; - - @IsInt() - @IsPositive() - @ApiProperty({ type: 'integer' }) - interval!: number; } export class SystemConfigLibraryDto { diff --git a/server/src/domain/system-config/system-config.core.ts b/server/src/domain/system-config/system-config.core.ts index eb661133b2..d32309955d 100644 --- a/server/src/domain/system-config/system-config.core.ts +++ b/server/src/domain/system-config/system-config.core.ts @@ -132,8 +132,6 @@ export const defaults = Object.freeze({ }, watch: { enabled: false, - usePolling: false, - interval: 10_000, }, }, server: { diff --git a/server/src/domain/system-config/system-config.service.spec.ts b/server/src/domain/system-config/system-config.service.spec.ts index ec0b4b8f4f..67a6418f44 100644 --- a/server/src/domain/system-config/system-config.service.spec.ts +++ b/server/src/domain/system-config/system-config.service.spec.ts @@ -136,8 +136,6 @@ const updatedConfig = Object.freeze({ }, watch: { enabled: false, - usePolling: false, - interval: 10_000, }, }, }); diff --git a/server/src/infra/entities/system-config.entity.ts b/server/src/infra/entities/system-config.entity.ts index 8307a0328e..edf4734359 100644 --- a/server/src/infra/entities/system-config.entity.ts +++ b/server/src/infra/entities/system-config.entity.ts @@ -51,8 +51,6 @@ export enum SystemConfigKey { LIBRARY_SCAN_CRON_EXPRESSION = 'library.scan.cronExpression', LIBRARY_WATCH_ENABLED = 'library.watch.enabled', - LIBRARY_WATCH_USE_POLLING = 'library.watch.usePolling', - LIBRARY_WATCH_INTERVAL = 'library.watch.interval', LOGGING_ENABLED = 'logging.enabled', LOGGING_LEVEL = 'logging.level', @@ -268,8 +266,6 @@ export interface SystemConfig { }; watch: { enabled: boolean; - usePolling: boolean; - interval: number; }; }; server: { diff --git a/server/src/infra/migrations/1709150004123-RemoveLibraryWatchPollingOption.ts b/server/src/infra/migrations/1709150004123-RemoveLibraryWatchPollingOption.ts new file mode 100644 index 0000000000..8b7ff3a675 --- /dev/null +++ b/server/src/infra/migrations/1709150004123-RemoveLibraryWatchPollingOption.ts @@ -0,0 +1,12 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RemoveLibraryWatchPollingOption1709150004123 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DELETE FROM "system_config" WHERE key = 'library.watch.usePolling'`); + await queryRunner.query(`DELETE FROM "system_config" WHERE key = 'library.watch.interval'`); + } + + public async down(): Promise { + // noop + } +} diff --git a/web/package-lock.json b/web/package-lock.json index 3b748e5971..5aa715313b 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-web", - "version": "1.2.0", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.2.0", + "version": "1.3.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@immich/sdk": "file:../open-api/typescript-sdk", diff --git a/web/package.json b/web/package.json index b7d400e861..73e025c6d1 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.2.0", + "version": "1.3.0", "license": "GNU Affero General Public License version 3", "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3000", diff --git a/web/src/lib/components/admin-page/settings/library-settings/library-settings.svelte b/web/src/lib/components/admin-page/settings/library-settings/library-settings.svelte index 746db2c198..c1076abc54 100644 --- a/web/src/lib/components/admin-page/settings/library-settings/library-settings.svelte +++ b/web/src/lib/components/admin-page/settings/library-settings/library-settings.svelte @@ -42,28 +42,6 @@ subtitle="Watch external libraries for file changes" bind:checked={config.library.watch.enabled} /> - - - - - -

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

-
-
diff --git a/web/src/lib/components/album-page/album-description.svelte b/web/src/lib/components/album-page/album-description.svelte new file mode 100644 index 0000000000..9e988ba75e --- /dev/null +++ b/web/src/lib/components/album-page/album-description.svelte @@ -0,0 +1,45 @@ + + +{#if isOwned} +